TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

988

こんにちは、計測プラットフォーム本部バックエンド部の髙木( @TAKAyuki_atkwsk )です。普段は ZOZOMAT や ZOZOGLASS などの計測技術に関する開発・運用に携わっています。ちなみにZOZOGLASSを使って肌の色を計測したところ、私のパーソナルカラーはブルーベース・冬と診断されました。 さて、本記事ではZOZOMATシステムで利用されていたNetwork Load BalancerをApplication Load Balancerに移行した事例をご紹介します。 ZOZOMATのシステム構成(2020年当時)に関しては、こちらの記事で詳しく説明されていますので合わせてご覧ください。 techblog.zozo.com 移行の背景 ZOZOTOWNアプリやZOZOTOWNシステムからZOZOMATシステムに対するリクエストの負荷分散のためにNetwork Load Balancer(以下、NLB)を利用していました。これは、ZOZOTOWNアプリからのリクエストがgRPCを利用することと、当時Application Load Balancer(以下、ALB)ではエンドツーエンドのHTTP/2対応がされていなかったことに拠ります。1年近くNLBを利用していましたが、2020年11月に エンドツーエンドのHTTP/2およびgRPCがALBにてサポートされた ことを受け、NLBからALBに移行しようということになりました。 NLBを利用していたときの課題 NLBがALPN対応していないため、TCPリスナーで構成しターゲットにはEnvoyを配置してTLS終端とALPNの役割を担う構成にしていました。このため、TLS証明書はAWS Certificate Manager(ACM)を利用できず、自前で購入・管理する必要がありました。環境を複数用意したい場合、その都度証明書を購入するかワイルドカード証明書を購入するか、という観点でも悩みどころとなっていました。 また、NLBではTCPリスナーだとアクセスログを取得できないことやレスポンスタイムなどのメトリクスを取得できないことも運用にあたっては少し不便でした。実際にはターゲットに配置しているEnvoyのアクセスログを利用することで補っていました。ALBではこれらの悩みが解決されますが、急激なトラフィック増加が見込まれる際には事前に暖機申請をして備えることが求められます。今回の移行時にこの点が懸念としてありましたが私たちのユースケースでは特に問題ありませんでした。 移行に関する課題 移行はシステムを停止させることなく行いたいという要望がありましたが、知見が少なかったことと、このような移行作業の経験もなかったため、注意深く検討する必要がありました。そのため、不明確だった以下の点の調査・検証を行いました。 ALB経由でgRPCリクエストを受け付けられるか 複数のIngressをグループ化して単一のALBでルーティングできるか ExternalDNSを利用したDNSレコード変更ができるか ロールバックできるか 本記事の後半では、そのなかでも複数のIngressをグループ化して単一のALBでルーティングできるか、ExternalDNSを利用したDNSレコード変更ができるか、の2点を紹介します。 移行方法 ZOZOMATシステムに紐付くドメインに対し、Route 53上でエイリアスレコードの値をNLBエンドポイントからALBエンドポイントへ切り替える方法で移行しました。NLB関連のリソースを残し、ALB関連のリソースを追加した状態で切り替えるため、切り替え後に不具合が起きた場合、すぐに切り戻しが可能な構成です。以下に切り替え前の構成図と切り替え時の構成図を示します。 構成図にはElastic Kubernetes Service(EKS)のクラスターや、Kubernetesのリソースも含めています。NLBやALB、そしてRoute 53のレコードがKubernetesリソースによって管理されるためです。 次に切り替えのために準備したものを紹介していきます。 ACMでのTLS証明書作成 ALBを利用する構成では ACMで発行したTLS証明書を使うことができる ので、あらかじめ発行しておきました。あらかじめ発行しておく理由は、後ほど触れるIngressリソースを定義する際に証明書のARNを参照するためです。 AWS Load Balancer Controllerのインストール AWS Load Balancer Controller をEKSクラスターにインストールします。これにより、Ingressリソースを追加すると連動してALBが作成されるようになります。ちなみに、v2.1.0からgRPCワークロードに対応していて、事前検証中にこのバージョンがリリースされ、良いタイミングで利用できました。 Kubernetesリソースの追加 今回追加したのはIngressリソースと、既存のServiceとは別のServiceリソースです。 Ingressは、AWS Load Balancer Controllerでアノテーションを設定していくことでALBの振る舞いを変えることができます。設定可能なアノテーションは以下のドキュメントを参照ください。 kubernetes-sigs.github.io ZOZOMATシステムでは、ZOZOTOWNアプリからはgRPCで、ZOZOTOWNサーバーからはHTTP/1.1(REST API)としてリクエストされることを考慮しなければなりませんでした。そのため、ALBを配置しても両方のプロトコルでリクエストを受けられるようにしておく必要があります。ALBでこれを実現するには、ターゲットグループを用意し、リスナーのルールによってそれぞれのターゲットグループにルーティングする方法を利用します。また、Ingressリソースの定義で実現できるかどうかも調査・検証しました。次章でご紹介します。 次に、Serviceリソースについて見ていきます。既存の構成では、LoadBalancerタイプのものを利用してNLBとして外部からアクセスする経路を作っていました。切り替え後の構成では、Ingressを利用してALBとして外部からアクセスする経路になるため、LoadBalancerタイプのServiceである必要はなくなります。そのため、新たにClusterIPタイプのServiceを用意してIngressと関連付けることにしました。 EnvoyのHTTPリスナーの作成 ロードバランサーのターゲットに配置されるEnvoyは、既存構成ではHTTPS用のリスナーのみ定義し、TLS終端やALPNを設定していました。切り替え後の構成では、ALBでこれらの役割を担うため、EnvoyにHTTP用のリスナーを追加しました。TLSの設定やポート番号以外は同じ定義です。 検証したこと 次に、今回検証した内容から2つのトピックをピックアップして紹介します。 検証1:複数のIngressをグループ化して単一のALBでルーティングできるか AWS Load Balancer Controllerに Ingress Group という仕組みがあり、これを利用すると複数のIngressを単一のALBに統合できます。これを設定すると、ALBではリスナールールおよびターゲットグループとして現れます。ZOZOMATシステムで実現したいgRPC、HTTP/1.1両プロトコルの設定をグルーピングする例を以下に示します。 # gRPC用のIngress apiVersion : networking.k8s.io/v1beta1 kind : Ingress metadata : name : zozomat-ingress-grpc namespace : default annotations : kubernetes.io/ingress.class : alb # グルーピングするIngress間で共通の値を使う alb.ingress.kubernetes.io/group.name : zozomat-ingress alb.ingress.kubernetes.io/scheme : internet-facing alb.ingress.kubernetes.io/target-type : ip alb.ingress.kubernetes.io/backend-protocol-version : GRPC # ... spec : rules : - http : paths : - path : /foo.BarService/* backend : serviceName : envoy-service servicePort : 80 # ... --- # HTTP/1.1用のIngress apiVersion : networking.k8s.io/v1beta1 kind : Ingress metadata : name : zozomat-ingress-http1 namespace : default annotations : kubernetes.io/ingress.class : alb # グルーピングするIngress間で共通の値を使う alb.ingress.kubernetes.io/group.name : zozomat-ingress alb.ingress.kubernetes.io/scheme : internet-facing alb.ingress.kubernetes.io/target-type : ip alb.ingress.kubernetes.io/backend-protocol-version : HTTP1 # ... spec : rules : - http : paths : - path : /foo/* backend : serviceName : envoy-service servicePort : 80 # ... このマニフェストを適用することで、単一のALBに対して複数のターゲットグループが関連付けられることを確認できました。 検証2:ExternalDNSを利用したDNSレコード変更ができるか ZOZOMATシステムでは ExternalDNS を利用しており、Kubernetesリソースを介してRoute 53のDNSレコードを制御する仕組みです。しかし、ExternalDNSに関して、既にService経由でDNSレコードが設定されている場合、Ingress経由で同じドメインのDNSレコードに対して操作が行えるかどうかが切り替える際に不確かな点でした。 LoadBalancerタイプのServiceに対してDNSレコードを作成するには external-dns.alpha.kubernetes.io/hostname アノテーションを設定します。詳しくは こちらのドキュメント に記載されています。 設定例を以下に示します。このマニフェストを適用するとNLBのエンドポイントをターゲットとする api.example.com のエイリアスレコードが作成されます。 apiVersion : v1 kind : Service metadata : name : envoy annotations : # 説明用のドメイン external-dns.alpha.kubernetes.io/hostname : api.example.com service.beta.kubernetes.io/aws-load-balancer-type : "nlb" service.beta.kubernetes.io/aws-load-balancer-internal : "false" spec : type : LoadBalancer # ... 続いて、この状態でIngressリソースを作成した場合、既存のエイリアスレコードに変更が発生するのかを確認していきます。 こちらのドキュメント を見ると、「Ingressの spec.rules[].host を設定するとDNSレコードが作成される」と書いてあります。 設定例を以下に示します。 apiVersion : networking.k8s.io/v1beta1 kind : Ingress metadata : name : zozomat-ingress-grpc namespace : default annotations : kubernetes.io/ingress.class : alb # あらかじめACMで発行したTLS証明書のARN alb.ingress.kubernetes.io/certificate-arn : arn:aws:acm:<region>:<account-id>:certificate/xxxxxx # ... spec : rules : # Serviceのアノテーションで設定したドメインと同じ - host : api.example.com http : paths : - path : /foo.BarService/* backend : serviceName : envoy-service servicePort : 80 # ... これを適用したところ、既存のエイリアスレコードに変更はありませんでした。 内部の挙動を理解しておきたかったので、ExternalDNSのログを見ました。以下に示すように、DNSレコードに反映する候補となるIngressリソースを検知はしているようですが、変更は行われなかったと見て取れます。 これに関連する処理のテストコード を見てみると、DNSレコードに反映する候補がいくつかある場合は、既存のDNSレコードと同じ値が含まれていれば変更しない挙動になっています。このことから、Serviceリソースのアノテーションで設定したドメインをhostとするIngressリソースを追加してもDNSレコードに影響を及ぼさないことが分かりました。 # ExternalDNSのログから抜粋(ドメイン名やhostedzoneを一部改編) # Serviceに関連するログ time="2020-12-02T09:11:59Z" level=debug msg="Endpoints generated from service: default/envoy: [api.example.com 0 IN CNAME xxx.elb.ap-northeast-1.amazonaws.com []]" # ここからIngressに関連するログ time="2020-12-02T09:11:59Z" level=debug msg="Endpoints generated from ingress: default/zozomat-ingress-http1: [api.example.com 0 IN CNAME yyy.ap-northeast-1.elb.amazonaws.com []]" time="2020-12-02T09:11:59Z" level=debug msg="Endpoints generated from ingress: default/zozomat-ingress-grpc: [api.example.com 0 IN CNAME yyy.ap-northeast-1.elb.amazonaws.com []]" time="2020-12-02T09:11:59Z" level=debug msg="Removing duplicate endpoint api.example.com 0 IN CNAME yyy.ap-northeast-1.elb.amazonaws.com []" # 特定のhostedzoneについては全レコードが最新の状態であるというログ # つまりapi.example.comのAレコード(エイリアスレコード)はNLBのエンドポイントに設定されたまま time="2020-12-02T09:11:59Z" level=debug msg="Considering zone: /hostedzone/ZZZZZZ (domain: example.com.)" time="2020-12-02T09:11:59Z" level=info msg="All records are already up to date" さらに、この状態からNLBと紐付くLoadBalancerタイプのServiceのアノテーションを削除するとどうなるのかを検証しました。その結果、エイリアスレコードのターゲットがNLBのエンドポイントからALBのものに切り替わりました。 作業時のログを以下に示します。DNSレコードに反映する候補としてServiceリソースは検知されなくなり、これによってIngressリソースの値が同じドメインのエイリアスレコードに反映されています。 以上の調査・検証で切り替え方が分かったので、最後にある程度の負荷を掛けながら今までの作業を試しました。特にリクエストが失敗することなくDNSレコードの値を切り替えることができました。 # ExternalDNSのログから抜粋(ドメイン名やhostedzoneを一部改編) # Serviceに関しては検知されなくなった time="2020-12-02T09:18:02Z" level=debug msg="No endpoints could be generated from service default/envoy" # ここからIngressに関連するログ time="2020-12-02T09:18:02Z" level=debug msg="Endpoints generated from ingress: default/zozomat-ingress-http1: [api.example.com 0 IN CNAME yyy.ap-northeast-1.elb.amazonaws.com []]" time="2020-12-02T09:18:02Z" level=debug msg="Endpoints generated from ingress: default/zozomat-ingress-grpc: [api.example.com 0 IN CNAME yyy.ap-northeast-1.elb.amazonaws.com []]" time="2020-12-02T09:18:02Z" level=debug msg="Removing duplicate endpoint api.example.com 0 IN CNAME yyy.ap-northeast-1.elb.amazonaws.com []" # 先ほどとは違いレコードの変更が行われたログが記録されている time="2020-12-02T09:18:03Z" level=debug msg="Considering zone: /hostedzone/ZZZZZZ (domain: example.com.)" time="2020-12-02T09:18:03Z" level=debug msg="Adding api.example.com. to zone example.com. [Id: /hostedzone/ZZZZZZ]" time="2020-12-02T09:18:03Z" level=debug msg="Adding api.example.com. to zone example.com. [Id: /hostedzone/ZZZZZZ]" time="2020-12-02T09:18:03Z" level=info msg="Desired change: UPSERT api.example.com A [Id: /hostedzone/ZZZZZZ]" time="2020-12-02T09:18:03Z" level=info msg="Desired change: UPSERT api.example.com TXT [Id: /hostedzone/ZZZZZZ]" time="2020-12-02T09:18:03Z" level=info msg="2 record(s) in zone example.com. [Id: /hostedzone/ZZZZZZ] were successfully updated" ここまでの一連の流れを図示したものを以下に示します。 さいごに 本記事で紹介した事前の準備や調査・検証により、本番環境での移行作業を滞りなく、かつシステムを停止せずに行うことができました。ALBに切り替えた後はTLS証明書の更新作業から開放され、新しい環境が必要になった際もACMで証明書を発行できるので、手軽に環境を構築できるようになりました。 これは個人的な副産物なのですが、一連の調査でAWS Load Balancer ControllerやExternalDNSのドキュメントおよびソースコードを読む機会がありました。少しずつ理解をしていくうちに、どちらのコンポーネントもあるリソースの状態をチェックして別のリソースをあるべき状態にするパターンなんだなと分かりました。さらに調べるとKubernetesの カスタムコントローラー という概念であることを知りました。作業に入る前はExternalDNSというものが「なんか良い感じにDNSレコードの制御をやってくれている」くらいの認識でしたが、その中身や周辺知識を知る良い機会になりました。 最後に、計測プラットフォーム本部バックエンド部では、サーバーエンジニア、SREエンジニア、それぞれでファッションにおける計測にまつわる課題解決を共に進めてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 www.wantedly.com www.wantedly.com
はじめに こんにちは。SRE部MLOpsチームの中山( @civitaspo )です。みなさんはGWをどのように過ごされたでしょうか。私は実家に子どもたちを預けて夫婦でゆっくりする時間にしました。こんなに気軽に実家を頼りにできるのも 全国在宅勤務制度 のおかげで、実家がある福岡に住めているからです。「この会社に入って良かったなぁ」としみじみとした気持ちでGW明けの絶望と対峙しております。 現在、MLOpsチームでは増加するML案件への対応をスケールさせるため、 Kubeflow を使ったMLOps基盤構築を進めています。本記事ではその基盤構築に至る背景とKubeflowの構築方法、および現在分かっている課題を共有します。 目次 はじめに 目次 MLOpsチームを取り巻く状況 MLOps基盤の要件 MLOps基盤技術としてのKubeflow Kubeflowの構築 ドキュメント通りにKubeflowを構築する Kubeflowを要件に合わせて構築する Config Connector用ManifestをTerraform化 Manifest群をKustomizeで参照しPatchを当てる 適切なNode Poolに配置する 運用課題 Istioが古い kubernetes-sigs/applicationが異常な量のログを出力する Kubeflow PipelinesとKubernetesの不整合 最後に MLOpsチームを取り巻く状況 冒頭で「増加するML案件への対応をスケールさせるため」と述べましたが、まずはその背景を私たちのチームが直面している状況を踏まえて説明します。 MLOpsチームは2019年4月に発足しました。当初はZOZO研究所がML機能開発を担当していたものの、プロダクションにその機能をリリースできていない課題がありました。その課題を解決するために、プロトタイプからプロダクションレベルへの引き上げをミッションとして発足したのがMLOpsチームです。このミッションは2年経った現在も変わっていません 1 。 ミッションは変わっていませんが、周囲を取り巻く環境は変わりました。なぜなら、着実にML機能のリリースを重ね、社内からの信頼度が高まってきているためです。これについて、もう少し深掘りして説明します。 私たちはチームの中長期目標として3つのフェーズを定めていました。 Phase1: ML機能を1つ、プロダクションに出す Phase2: ML機能を複数、プロダクションに出す Phase3: ML機能の量産体制を整える Phase1ではML機能を1つのプロダクションに出すことが目標でした。これは、私たちがレベルの高いインフラ、つまり技術選定が妥当である、安定している、十分に高速であるインフラを構築可能であると示すことで、MLOpsチームに対する社内からの信頼を獲得するための目標でした。同時に技術的なプレゼンスを高め、社外に対して発信することも目標に含んでいました。その最初のML機能が画像検索でした。 techblog.zozo.com Phase2ではML機能を複数のプロダクションに出すことが目標でした。ここでは、Phase1で実践したレベルの高いインフラ構築、およびそれを用いたML機能のプロダクションリリースの再現性を示すことが重要でした。全ての事例は載せられないので、検索パーソナライズと推薦の代表的な2例を紹介します。 techblog.zozo.com techblog.zozo.com そして、現在はPhase3の目標である、ML機能の量産体制の整備に取り組んでいます。Phase2までの取り組みで社内からの信頼を確実なものとしました。そのため、ML機能をリリースする案件も以前より増加しています。社外に対する技術的なプレゼンス向上も、実際に優秀な人材の採用に繋げられています。 しかし、案件の増加スピードに対して人材の増加が追いつかなくなる未来も見え始めています。そのため、ML機能のリリースをスケールさせるような基盤構築を進めています。この基盤が本記事で「MLOps基盤」と呼んでいるものです。複数のML機能をリリースしたことで、ML機能をプロダクションへリリースするために必要な共通要素・デザインパターンが分かってきました。その経験を元にMLエンジニアと協力して要件整理・検証を進めています。 MLOps基盤の要件 構築を進めているMLOps基盤の説明の前に、私たちの考えるMLOps基盤とは何かを説明します。 私たちの考えるMLOps基盤とは以下の要件を満たすものです。 運用中の予測モデル(ワークフロー)を一元管理できること モデル作成の際に環境構築が容易であること 実験段階からプロダクションへの移行が容易であること 車輪の再発明をしないような仕組みであること(= 似たようなモデル開発をしない) モデルサービングが可能であること 以前にAI Platform Pipelinesを取り上げた記事でも言及した内容ですが、改めて本記事でも説明します。 techblog.zozo.com まず、「運用中の予測モデル(ワークフロー)を一元管理できる」必要があります。少人数で多数のML機能をリリースするためにはプロジェクト間・環境間の差分を極力排除し、構築・運用が共通化されていなければなりません。 同様の理由で「モデル作成の際に環境構築が容易である」ことも重要です。プロジェクト・環境が異なっても同じ方法で実験を開始できれば、その分だけMLエンジニアはモデル開発に集中できます。 さらに「実験段階からプロダクションへの移行が容易である」ことも必須です。実験段階のコードとプロダクションのコードが大幅に異なる場合、実験時と同じ結果を得られる保証がありません。そのため、再度検証が必要となり、大きな工数が必要となります。 「車輪の再発明をしないような仕組みである」ことはエンジニアなら当然考えることですが、MLOpsの文脈では過去の実験を再現可能であることが重要です。過去の実験をカタログのように扱い、新たなML機能をリリースする際にも過去の実験を参考・流用できる状態にしておく必要があります。 最後の「モデルサービングが可能である」ことは、モデルを構築すればそのままサービングが可能であることを求めています。モデルを構築しても別途サービング用のコードを書く必要がある場合、実装工数が必要となる他、学習時にオフライン評価で使用した推論結果とサービング時の推論結果が一致していることを保証する必要もあります。そのため、モデルサービングをフレームワークレベルでサポートし、MLエンジニアはモデル作成に専念できる状態を目指しています。 MLOps基盤技術としてのKubeflow KubeflowはMLに必要な全てのワークロードを Kubernetes 上で実現するツールキットです。Kubeflowそのものに関しては 先ほど紹介したAI Platform Pipelinesを取り上げた記事 で説明しているので割愛します。Kubeflowに関する知見は既に社内で溜まりつつあり、またMLOps基盤としての要件を十分に満たす機能を持っていたため採用を決めました。 特に[Kubeflow Pipelines]( https://www.kubeflow.org/docs/components/pipelines/overview/pipelines-overview/ )の非常に高い実験管理機能は魅力的でした。Kubeflow Pipelinesはワークフローエンジンとして内部で[Argo Workflows]( https://argoproj.github.io/projects/argo )を利用しています。Argo Workflowsではワークフローのタスク1つ1つがPodとなっているため、元データと使用するイメージに変更が無ければ多くのケースで何度でも同じ挙動を再現できます。Kubeflow Pipelinesではワークフローの実行ごとに、実行時メタデータだけでなくワークフローの定義自体も含めて保存しているため、過去の実行を容易に再現できます。 また、Kubeflowは[マルチテナンシーをサポート]( https://www.kubeflow.org/docs/components/multi-tenancy/ )しており、単一のKubeflowで複数のプロジェクトを管理できます。Kubeflow内部で[Profile]( https://www.kubeflow.org/docs/components/multi-tenancy/design/ )という単位で権限を管理できる機能を持っており、プロジェクト間で厳密な権限管理を行いつつ、Kubeflowという基盤に実験を集約することが可能です。1つの基盤を運用すれば良いので運用工数も大幅に削減できます。 そのため、MLOps基盤を構築する最初の目標としてマルチテナンシーが有効化されたKubeflowを構築し、Kubeflow Pipelinesを利用できる状態を目指しました。前置きが長くなりましたが、本記事ではこの目標を達成するためにKubeflowを構築した際に得られた知見、課題を共有します。 # なぜAI Platform Pipelinesを使わないのか Kubeflow構築の説明をする前に、なぜ AI Platform Pipelines を使わなかったか触れておきます。MLOpsチームは Google Cloud Platform(以下、GCP) を使っているため、Kubeflow Pipelinesの代わりにGCPのマネージドサービスであるAI Platform Pipelinesを利用することも検討しました。しかし、複数の観点から採用を見送りました。 まず、AI Platform Pipelinesは1つのプロジェクトを作成する毎に1つの Google Kubernetes Engine(以下、GKE) が構築されてしまう点です。 AI Platform Pipelinesは1つのGKEクラスタに複数構築することができない ので、プロジェクトを増やす毎にGKEを構築する必要があります。GKEが増えれば増えるほど、GKEのバージョンアップ、監査ログ取得ツール Falco などの共通コンポーネントのインストール、などのクラスタ管理コストが増えてしまうため、運用がスケールしないと判断しました。 また、AI Platform Pipelinesの内部で保持するワークフローのデータなどをGKEに依存せず永続化するためには Cloud SQL を利用することになります。しかし、これに関してもプロジェクト増加毎に1インスタンス必要となりコスト面で許容できませんでした。Cloud SQLを使用しない場合は、GKE上に StatefulSet として MySQL がデプロイされ、 Persistent Volume に依存する構成となります。つまり、Zoneに依存する構成となり耐障害性が低くなってしまいます。 そして、一番課題と感じた点は利用者側でGKEにApplyされたManifestを直接書き換えても強制的に巻き戻ってしまう点です。問題発生時にManifestを修正することで問題解決できず、サポートケースを上げて解決することになるため、問題解決までのリードタイムが長くなってしまいます。 これらの理由によりMLOps基盤としてAI Platform Pipelinesの採用を見送りました 2 。 Kubeflowの構築 さて、Kubeflowを構築する話に移っていきます。なお、今回構築したKubeflowは v1.2.0 で、GKE 1.18.16-gke.502を使用しています。 ドキュメント通りにKubeflowを構築する 最初に Kubeflowの公式ドキュメント に沿ってKubeflow構築を進めました。このドキュメントに従うと、以下のように構成管理用GKEクラスタを使用して構築を進めることになります。 構成管理用GKEクラスタでは Config Connector を有効化しています。Config ConnectorはKubernetesを介してGCPのリソース操作を可能にするGKEアドオンです。このアドオンをインストールするとKubernetesにGCPのリソースを定義するための Custom Resource Definitions が使用可能になります。例えば、以下のようなManifestをApplyすると sample-gcp-project というGCP Projectに kubeflow-admin という名称の Service Account が定義されます。 apiVersion : iam.cnrm.cloud.google.com/v1beta1 kind : IAMServiceAccount metadata : name : kubeflow-admin namespace : sample-gcp-project labels : kf-name : kubeflow spec : displayName : kubeflow admin service account Kubeflowの公式ドキュメントでは、以下の手順でKubeflowを構築します。 ①Config Connectorを有効化した構成管理用GKEクラスタを構築する ②Config Connectorを使用してGCPのリソースを作成する ③構築したGKE上へKubeflowに必要なManifest群をApplyする これらの手順がMakefileに記述されており、 make apply で構築が完了するようになっています。 この手法は構成管理用GKEクラスタを構築する必要があるという点もさることながら、以下のような問題がありました。 既に構成管理に使用している Terraform と役割が競合してしまう 既にManifest管理に使用している Kustomize を使用できない Config Connectorで作成されるGCPリソースが私たちのインフラ要件を満たさない 3 依存コンポーネントとしてMySQLや MinIO を利用するためPersistent Volumeに依存してしまう これらの問題を解決しつつKubeflowを構築できるよう、次に示すような方法で構築しました。 Kubeflowを要件に合わせて構築する 私たちの環境に合わせた運用が可能になるよう、以下の方針でKubeflow構築を進めることにしました。 Kubeflowの公式ドキュメントに沿ってManifest群の生成まで進める Config Connectorで定義されたGCPリソースはTerraformで管理可能なように移植する 出力したManifest群はKustomizeで必要なファイルのみ参照するようにし、必要に応じてPatchを当てる まず、Kubeflowの公式ドキュメントに沿ってManifest群を生成します。 $ kpt pkg get https://github.com/kubeflow/gcp-blueprints.git/kubeflow@v1. 2 . 0 kubeflow $ cd kubeflow $ make get-pkg 上記コマンドでManifest群生成に必要なファイルを準備し、可能な限り私たちのインフラ要件に合うように一部のファイルを修正します。 $ vim Makefile 55c55 < kpt cfg set ./instance gke.private false --- > kpt cfg set ./instance gke.private true # VPCネイティブクラスタで構築するため 57c57 < kpt cfg set ./instance mgmt-ctxt <YOUR_MANAGEMENT_CTXT> --- > kpt cfg set ./instance mgmt-ctxt null # 構成管理用GKEクラスタは利用しないため 59,62c59,62 < kpt cfg set ./upstream/manifests/gcp name <YOUR_KF_NAME> < kpt cfg set ./upstream/manifests/gcp gcloud.core.project <PROJECT_TO_DEPLOY_IN> < kpt cfg set ./upstream/manifests/gcp gcloud.compute.zone <ZONE> < kpt cfg set ./upstream/manifests/gcp location <REGION OR ZONE> --- > kpt cfg set ./upstream/manifests/gcp name kubeflow > kpt cfg set ./upstream/manifests/gcp gcloud.core.project sample-gcp-project > kpt cfg set ./upstream/manifests/gcp gcloud.compute.zone asia-northeast1 > kpt cfg set ./upstream/manifests/gcp location asia-northeast1 65,66c65,66 < kpt cfg set ./upstream/manifests/stacks/gcp name <YOUR_KF_NAME> < kpt cfg set ./upstream/manifests/stacks/gcp gcloud.core.project <PROJECT_TO_DEPLOY_IN> --- > kpt cfg set ./upstream/manifests/stacks/gcp name kubeflow > kpt cfg set ./upstream/manifests/stacks/gcp gcloud.core.project sample-gcp-project 68,71c68,71 < kpt cfg set ./instance name <YOUR_KF_NAME> < kpt cfg set ./instance location <YOUR_REGION or ZONE> < kpt cfg set ./instance gcloud.core.project <YOUR PROJECT> < kpt cfg set ./instance email <YOUR_EMAIL_ADDRESS> --- > kpt cfg set ./instance name kubeflow > kpt cfg set ./instance location asia-northeast1 > kpt cfg set ./instance gcloud.core.project sample-gcp-project > kpt cfg set ./instance email takahiro.nakayama@example.com $ vim instance/gcp_config/kustomization.yaml 13a14,16 > - ../../upstream/manifests/gcp/v2/privateGKE/ > patchesStrategicMerge: > - ../../upstream/manifests/gcp/v2/privateGKE/cluster-private-patch.yaml 修正が完了したらManifest群を生成します。 $ make set-values $ make clean-build $ make hydrate これにより .build ディレクトリ以下に大量のManifest群が生成されます。 $ find .build -type f | head -n10 .build/cert-manager-crds/apiextensions.k8s.io_v1beta1_customresourcedefinition_certificates.cert-manager.io.yaml .build/cert-manager-crds/apiextensions.k8s.io_v1beta1_customresourcedefinition_challenges.acme.cert-manager.io.yaml .build/cert-manager-crds/apiextensions.k8s.io_v1beta1_customresourcedefinition_orders.acme.cert-manager.io.yaml .build/cert-manager-crds/apiextensions.k8s.io_v1beta1_customresourcedefinition_issuers.cert-manager.io.yaml .build/cert-manager-crds/apiextensions.k8s.io_v1beta1_customresourcedefinition_certificaterequests.cert-manager.io.yaml .build/cert-manager-crds/apiextensions.k8s.io_v1beta1_customresourcedefinition_clusterissuers.cert-manager.io.yaml .build/iap-ingress/networking.gke.io_v1beta1_managedcertificate_gke-certificate.yaml .build/iap-ingress/v1_configmap_ingress-bootstrap-config.yaml .build/iap-ingress/rbac.istio.io_v1alpha1_clusterrbacconfig_default.yaml .build/iap-ingress/cloud.google.com_v1beta1_backendconfig_iap-backendconfig.yaml $ find .build -type f | wc -l 505 これら全てのファイルを気合で読み進め、Terraform化、Kustomize化を進めます。 Config Connector用ManifestをTerraform化 ここまでの手順でConfig Connector向けのManifestも出力されるので、Terraform管理可能な定義に変換していきます。Config Connector向けのManifestは .build/gcp_config 以下のファイル群です。 これらファイル群で定義されているGCPリソースは以下の通りです。 Virtual Private Cloud Static IP Persistent Disk Firewall rules Cloud Router Cloud NAT Cloud DNS Cloud IAM GKE 各種APIの有効化 GKEを構築済みである場合、Virtual Private CloudやCloud NATなどは既に存在しているはずなので、リソース作成の要不要は定義を読んで判断する必要があります。私たちの場合はFirewall rulesとCloud IAM以外は不要でした。 Manifest群をKustomizeで参照しPatchを当てる 生成したManifest群をKustomizeで参照するために、以下のようなディレクトリ構成をとることにしました。 . ├── generated │   └── kubeflow │      └── .build │         ├── application │         ├── cert-manager │         ├── cert-manager-crds │         ├── cert-manager-kube-system-resources │         ├── cloud-endpoints │         ├── gcp_config │         ├── iap-ingress │         ├── istio │         ├── knative │         ├── kubeflow-apps │         ├── kubeflow-issuer │         ├── metacontroller │         └── namespaces ├── base # generatedを参照する │   ├── application │   ├── cert-manager │   ├── cert-manager-leaderelection │   ├── cluster-resources │   ├── falco │   ├── iap-ingress │   ├── istio │   ├── knative │   ├── kubeflow-apps │   │   ├── argo │   │   ├── centraldashboard │   │   ├── jupyter-web-app │   │   ├── katib │   │   ├── kfserving │   │   ├── metadata │   │   ├── minio │   │   ├── ml-pipeline │   │   ├── notebook-controller │   │   ├── poddefaults │   │   ├── profiles │   │   ├── pytorch │   │   └── tfjob │   ├── kubeflow-issuer │   ├── kubeflow-istio │   ├── metacontroller │   └── nvidia-driver-installer ├── dev # baseを参照する ├── stg # baseを参照する └── prd # baseを参照する generated/kubeflow/.build 以下のディレクトリに先ほど生成したManifest群が格納されています。生成したManifestへは直接変更を加えず base 以下のディレクトリに格納する kustomization.yaml から参照、Patchを加えます。 例えば、 base/kubeflow-apps/argo/kustomization.yaml で記述されているArgo Workflowsの設定は以下のようになります。 apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization namespace : kubeflow resources : - ../../../generated/kubeflow/.build/kubeflow-apps/apiextensions.k8s.io_v1beta1_customresourcedefinition_workflows.argoproj.io.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/app.k8s.io_v1beta1_application_argo.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/apps_v1_deployment_argo-ui.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/apps_v1_deployment_workflow-controller.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/networking.istio.io_v1alpha3_virtualservice_argo-ui.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/rbac.authorization.k8s.io_v1beta1_clusterrole_argo-ui.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/rbac.authorization.k8s.io_v1beta1_clusterrole_argo.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/rbac.authorization.k8s.io_v1beta1_clusterrolebinding_argo-ui.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/rbac.authorization.k8s.io_v1beta1_clusterrolebinding_argo.yaml # NOTE : We use the configMapGenerator instead of these files. # - ../../../generated/kubeflow/.build/kubeflow-apps/v1_configmap_workflow-controller-configmap.yaml # - ../../../generated/kubeflow/.build/kubeflow-apps/v1_configmap_workflow-controller-parameters.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/v1_service_argo-ui.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/v1_serviceaccount_argo-ui.yaml - ../../../generated/kubeflow/.build/kubeflow-apps/v1_serviceaccount_argo.yaml configMapGenerator : # ref. https://github.com/argoproj/argo/blob/v2.3.0/docs/workflow-controller-configmap.yaml - name : argo-workflow-controller-config files : - config=config/workflow-controller.yaml configurations : - varReference.yaml vars : - name : ARGO_WORKFLOW_CONTROLLER_CONFIGMAP_NAME objref : kind : ConfigMap name : argo-workflow-controller-config apiVersion : v1 fieldref : fieldpath : metadata.name patchesStrategicMerge : - apps_v1_deployment_workflow-controller.yaml - v1_serviceaccount_argo.yaml このような定義をKubeflowに含まれる全てのコンポーネントに行っていきます。そして、各環境用ディレクトリから base を参照する構成です。 先ほど課題に挙げていたPersistent Volumeへの依存も base でPatchを当てることで解消しました。 MySQLを MySQL for Cloud SQL へ変更 MinIOを MinIO GCS Gateway へ変更 Kubeflowが公式に用意している kubeflow/manifests というリポジトリには様々なパターンへ対応するためのManifestが格納されています。そこに、 MySQL for Cloud SQLやMinIO GCS Gatewayを利用するパターン も用意されていました 4 。 適切なNode Poolに配置する ここまでの内容でKubeflowの構築が完了しました。構築に関する知見共有の最後に Node Pool の構成について触れておきます。 MLOps基盤ではKubeflowのController系Podを載せるNode Poolと、ワークフローのPodを載せるNode Poolを別々に管理する方針にしています。 KubeflowのController系Podを載せるNode Poolは、用途毎に占有のNode Poolを作成しました。用途以外のPodが配置されないように taint を設定し、占有対象のPodが配置されるように tolerations と nodeAffinity を設定します。 以下のようなPatchを定義し、 kustomization.yaml でPatchを当てます。 # dedicated-node-pool-patch.yaml - op : add path : /spec/template/spec/affinity value : nodeAffinity : requiredDuringSchedulingIgnoredDuringExecution : nodeSelectorTerms : - matchExpressions : - key : cloud.google.com/gke-nodepool operator : In values : - kubeflow - op : add path : /spec/template/spec/tolerations value : - key : dedicated operator : Equal value : kubeflow effect : NoSchedule # kustomization.yaml apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization namespace : kubeflow resources : - <...snip...> patches : - target : kind : StatefulSet path : dedicated-node-patch.yaml - target : kind : Deployment path : dedicated-node-patch.yaml 一方で、ワークフローのPodを載せるNode Poolは占有のNode Poolを作っていません。 Node Auto Provisioning でワークロード毎にNode Poolを自動でプロビジョニングするようにしています。 なお、Node Auto ProvisioningはGKEのアドオンの1つです。このアドオンを有効化するとScheduleされたPodの Resource Request 、 nodeAffinity や labelSelector 、 taint と tolerations から最適な設定のNode Poolが自動で作成されます。MLOps基盤として利用者のワークフローがどれだけのリソースを必要とするのか事前に把握するのは困難であるため、Node Auto ProvisioningでオンデマンドにNode Poolが作成される構成としました。 Node Auto Provisioningの良いところは、 GPU のプロビジョニングもサポートしているところです。利用者が必要なタイミングで何の相談も無くGPUが利用できる状態を作ることができます。 最終的には以下のようなNode Poolができています。 nap- から始まるNode PoolがNode Auto Provisioningによって生成されたNode Poolです。 運用課題 ここからは運用課題をいくつか紹介します。 Istioが古い Kubeflowで利用される Istio はv1.4です。Istioの最新バージョンはv1.9ですので非常に古いです。 また、v1.5で これまでマイクロサービスとして存在していたコンポーネント群がistiodに統合される大きなアーキテクチャ変更 がありました。そのため、現状のv1.4からバージョンが上がらないことに大きな危惧を感じています 5 。このGKEクラスタ上でサービングを始める前に解消されるべき課題です。 実は、Argo Workflowsも非常に古いバージョンである2.3.0(最新は3.0.0)を使用しています。Argo Workflowsに関しては、Kubeflow Pipelinesが依存しているのみなので、Istioほど大きな危惧は抱いていません。しかし、Kubeflowの依存コンポーネントがバージョンアップできない問題は今後も頭を悩ませ続けそうです。 kubernetes-sigs/applicationが異常な量のログを出力する kubernetes-sigs/application はアプリケーションを構成する全てのコンポーネントを束ねて扱えるCustom Resource Definitionsを提供するプロジェクトです。Kubernetesで定義可能な Deployment などの単位ではアプリケーション全体を管理できないという課題から作られたようです。kubernetes-sigs/applicationはKubeflowの依存コンポーネントですが、構築直後からデフォルトで非常に大量のログを出力するようになっています。 私が構築したときは秒間1000件以上のログを出力していました。GKE上でこの量のログが出力されると Cloud Logging のコストが高額になってしまいます。この問題はKubeflow側でも認識されていて、Issue( Stackdriver Logs are very expensive for kubeflow - kubeflow/gcp-blueprints#184 )になっています。その、 kubeflow/gcp-blueprints#184 ではkubernetes-sigs/applicationのログを全て /dev/null に捨てるという豪快なアプローチで解決が図られています。しかし、私たちはkubernetes-sigs/applicationを削除することにしました。なぜなら、kubernetes-sigs/applicationが存在しなければ動かないコンポーネントがKubeflowに存在しないからです。 Kubeflow PipelinesとKubernetesの不整合 Kubeflow Pipelinesは自身のDBに持つ状態を正として扱います。 一方、Kubernetes上の状態がKubeflow Pipelinesの持つ情報と異なっていても、Kubernetes上の状態を修正しません。また、Kubeflow PipelinesのUIからはDBに格納されている情報が表示されるのみで、その不整合状態を確認できません。そのため、Kubeflow Pipelinesの持つ情報とKubernetes上の状態との差異が発生すると、実際の状態を誤認してしまいます。 そして、この不整合状態は比較的高い確率で起こることが確認できています。原因が不明なものもあるため、確実に原因が分かっている2つのケースを紹介します。 1つ目はKubeflow Pipelinesによって作成されたObjectを削除するケースです。このケースは手動運用が禁じられている本番環境では起きえないので深く考える必要はありません。 もう1つは OwnerReference によって親Objectと共にObjectが削除されてしまうケースです。分かりにくいと思うので図を用いて説明します。 Kubeflow PipelinesではSchedule実行のために Recurring Run という機能があります。Recurring Runは時間になったら Run という機能でワークフローを実行します。Recurring RunとRunはKubernetes上でOwnerReferenceによって親子関係ができています。そのため、Runを実行中にRecurring Runを削除した場合、Runも一緒に削除されてしまいます。 しかしながら、Kubeflow Pipelines上で明示的に削除が行われたわけではないため、Kubeflow PipelinesのUIではRunは実行中ステータスのままになってしまうのです。 非常に危険な問題なので、Kubeflow Pipelinesの保持する状態とKubernetes上の状態を比較、監視する仕組みを導入しようと思っています。 最後に 本記事では現在構築中のMLOps基盤を紹介しました。記事内で取り上げた課題は解決に向けて絶賛取り組んでいるところです。特にIstioの最新化は急ピッチで進めています。また、インフラ部分だけではなく、MLOps基盤としてMLエンジニアをサポートする機能強化を実施していきたいと思っています。折を見て記事を書きますので期待して待っていて頂けると嬉しい限りです。 本記事に載せた内容以外にも様々な観点で機能を検証追加しています。絶賛構築中なので、関心を持たれた方は1度お話しをさせてもらえるとありがたいです。是非助けてください! ZOZOテクノロジーズでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! https://tech.zozo.com/recruit/ tech.zozo.com https://hrmos.co/pages/zozo/jobs/0000031 hrmos.co ミッションや文化に関する詳しい説明は前リーダーである @sonots さんが 『ZOZO MLOps のチームリーディングとSRE(Engineering)』 で語っています。興味のある方はご覧ください。 ↩ 2021-05-19にAI PlatformがVertex AIという名前となりPipelinesからGKE依存がなくなった ので再検討の余地があります。 ↩ 例えば私たちは 『GCP Shared VPCを利用した全社共通ネットワークの運用におけるDedicated Interconnect利用設定の最適化手法』 で説明したように Shared VPC を使用しています。そのためサービスプロジェクト側からホストプロジェクト側のFirewall rulesを操作することを認めていません。 ↩ 実は私たちのMySQL for Cloud SQLは Private Service Access を構成しています。そのため Cloud SQL Auth Proxy を使う構成では無く、単に接続情報を変更するだけで済みました。 ↩ issue は存在しています。 ↩
はじめに こんにちは。マイグレーションチームの藤本です。 この記事では、 先日のリニューアル に伴って導入したBackends For Frontends(以下、BFF)で、Redisを使ったキャッシュの事例をご紹介します。キャッシュを導入する際に起きる問題とその回避策について、サーバーサイドのアプリケーションで行った対策をもとに紹介していきます。 ZOZOTOWNリニューアルとBFF ZOZOTOWNで導入したBFFは、複数のAPIのレスポンスをフロントエンドが必要とする形式に集約して返却することを主な目的としています。これまでの実績から、大規模セール時のアクセス数は通常時の何倍にもなることがわかっており、BFFもそれに耐えられるパフォーマンスが必要です。 しかし、BFFに来たすべてのアクセスをそのままAPIに流すと、パフォーマンスに影響する恐れが出てきました。そのため、APIからのレスポンスの一部をキャッシュとして保存しています。このキャッシュの仕組みにRedisを利用しています。 BFFを導入した経緯や構成、ZOZOTOWNにおけるBFFの目的についての解説は、こちらの記事をご覧ください。 techblog.zozo.com techblog.zozo.com キャッシュ利用時の注意点 レスポンスの一部をキャッシュとして保存しても、無期限に持ち続けて良いわけではありません。ZOZOTOWNでは商品やショップなどの情報は常に更新されているため、キャッシュを一定期間で破棄して最新の情報を再取得する必要があります。 アクセス数が少ない場合はそれほど問題にはなりませんが、ECサイトのように常に大量のアクセスがある場合は、キャッシュが破棄されたタイミングでAPIにも同時に大量のアクセスが発生してしまいます。 この現象は一般的に Cache Stampede(キャッシュスタンピード) 、 Dog piling(ドッグパイル) などの名称で呼ばれています。 この記事では キャッシュスタンピード と呼びます。 キャッシュスタンピードの回避 ひとたびキャッシュスタンピードが起きると、APIの負荷が増えることによるパフォーマンス低下、データベースの処理遅延、最悪の場合はサイト全体の遅延や停止などにつながる可能性があります。 これを回避するための方法として代表的なものが3つあります。 別プロセスで事前にキャッシュを生成する(事前作成) 期限切れ前に一定の確率で期限を更新する(期限更新) 裏側のAPIへアクセスするプロセスを絞る(ロック) それぞれの方法のメリットとデメリットを見ていきます。 代表的な回避方法の比較 事前作成 1つ目の「別プロセスで事前にキャッシュを生成する」方法は、キャッシュの書き込みと読み取りをそれぞれ別のアプリケーションとして作るので、処理がシンプルになります。そして、キャッシュが期限切れになる前に新しいデータを準備できるため、キャッシュヒット率を上げられることがメリットです。 デメリットは、管理対象のアプリケーションが増えるため運用保守のコストが増加する点と、ユーザーの検索条件を予測できないので事前に最適なキャッシュを生成しづらい点です。 期限更新 2つ目の「期限切れ前に一定の確率でキャッシュを更新する」方法のメリットは、1つ目と同様に事前にキャッシュを生成するので期限切れになる心配が少ないことです。 デメリットはどれくらい前から更新し始めるか、確率はいくらにするのかといった値を、運用開始後にも定期的に見直す必要が出てくる点です。 ロック 3つ目の「裏側のAPIへアクセスするプロセスを絞る」は、簡単に言えばロックを取得する方法です。メリットは管理対象のアプリケーションは増やさず、数値の調整などの運用時の調整もあまり必要としないため、3つの方法の中で最も運用時のコストが抑えられることです。 デメリットは、事前に新しいデータを準備できないため一時的にキャッシュ切れが発生することや、長時間のロックはできないのでキャッシュ生成にかけられる時間が短いことです。 キャッシュの期限切れ 運用コスト 事前作成 少ない 高い 期限更新 少ない ほどほど ロック 多い 低い ロックを選択 リニューアルの開発を進めていく中で、上記の3つの方法を比較検討していました。その際の制約として、以下の2点がありました。 別プロセスでキャッシュを生成する仕組みが無いので追加開発が必要となる 日付が変わる時など、固定でキャッシュを破棄するタイミングがある まず、別プロセスでキャッシュを生成する方法は、現在のZOZOTOWNでは仕組みが存在せず、追加開発が必要でした。リニューアルの開発を進めている途中でキャッシュの導入が決まったため、スケジュールの都合で見送ることになりました。 また、ZOZOTOWNでは頻繁に日付や特定の時間を過ぎたタイミングで商品の販売開始や終了が発生したり、価格やクーポンなどの情報の変更が発生したりします。例えば、23時59分に生成したキャッシュが、1分後の24時00分には使えなくなってしまうことが起こりえます。期限を更新したキャッシュが無駄になってしまうことは避けたいと考えました。 今回は残る選択肢として、「裏側のAPIへアクセスするプロセスを絞る」方法を採用しました。 SETNXコマンド 前述の通り「裏側のAPIへアクセスするプロセスを絞る」ために、ロックを取得します。 BFFは多数のサーバーで稼働しているので、内部でロックを制御しても意味がありません。そこでRedisを使ってロックを制御します。 RedisにはSETNXという便利なコマンドが存在します。 redis.io SETNXコマンドの特徴は次の通りです。 通常のSETコマンドと同様に、key-valueのペアで登録できる 既にvalueが登録済みのkeyを指定すると、上書きできず失敗となる 成功したら1、失敗したら0が返ってくる 今回はこのSETNXコマンドの特徴を利用し、成功時のみ裏側のAPIへのアクセスを許可、失敗時はAPIへのアクセスを許可しないという制御をしています。こうすることで複数のサーバーで動作しているアプリケーションでもロックが可能になります。 この制御を Spring Framework の RedisTemplate を使った場合、以下のコードのように記述できます。 setIfAbsent() がRedisのSETNXコマンドに対応しています。 public boolean lock(String value, long ttl){ String key = lockKey(); Boolean result = redisTemplate .opsForValue() .setIfAbsent(key, value, ttl, TimeUnit.MILLISECONDS); return result != null && result; } SETNXコマンドでロックが取得できなかったプロセスは、キャッシュもなく最新の情報も取得できない状況にあるため、そのままではレスポンスを返せなくなってしまいます。この状況はできるだけ回避したいので、ロックを取得したプロセスが新しいキャッシュを登録するのを待つようにしています。 Redlockアルゴリズム ロックを取得するプロセスを厳密に1つに絞るならば、本来はRedlockアルゴリズムを用いないといけません。詳しい説明はこちらの公式ページ、または翻訳ページに記載があります。 redis.io redis-documentasion-japanese.readthedocs.io 簡単に説明すると、以下のような考え方です。 ロック取得後に対象のMasterノードが落ちると、ロックが失われて別のプロセスからもう一度ロックが可能になってしまう すべてのMasterノードに対してロックを試行して、過半数が取得できたらロック成功とみなす 今回のリニューアルでロックを必要とした場面は、商品やショップなどの情報を取得するためであり、データベースに保存されたデータを更新するためではありません。そのため、トランザクションのような厳密さは必要ないと判断して、Redlockは使わずにSETNXコマンドを使用しています。 重要なデータを更新するなどの厳密さが求められる場面では、Redlockアルゴリズムを用いるか、ミドルウェアのトランザクション機能を使うほうが良いです。 効果 以下のグラフは、負荷試験を行っている際に、裏側のとあるAPIへのアクセス状況をDatadogでグラフ化したものの抜粋です。 Before After 何も対策していない場合は、定期的に15 req/sec流れていましたが、対策後は5 req/secと、約1/3に抑えることができました。 まとめ Redisを使ったキャッシュへの取り組みと注意点、その回避方法を本記事ではご紹介しました。対策を施すことでAPIへの負荷も減り、現在は想定していたパフォーマンスを維持できています。 しかしながら、今後はよりアクセス数も増加し、それぞれのユーザーに合わせたおすすめの表示など、検索パターンや表示される商品のバリエーションが増加します。そうなると、キャッシュの意味が薄れてしまい、パフォーマンスの低下につながってしまいます。 ZOZOTOWNのリプレイスを進めつつも、既に置き換えが済んでいるAPIのパフォーマンス改善もあわせて考える必要が出てきています。 さいごに ZOZOTOWNのリプレイスはまだまだ道半ばの段階です。新規機能の追加とパフォーマンスの維持を両立させながら前へ進む必要があるため、考えることがたくさんあります。 ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co
はじめに 2020年新卒入社で、現在ZOZOWEB部所属の 武井 です。ZOZOTOWNのWebフロントエンド開発を担当しています。私は入社以来オフィスに2度しか出社したことがありませんが、そのうちの1度は スタッフインタビュー記事 の撮影のときでした。アートがたくさんある素敵なオフィスですが、それ以降出社できていません。まさか新卒1年目からフルリモート勤務をすると思っていませんでしたが、先輩スタッフが仕組み作りをしてくださっていたおかげで快適に働けています。 さて、本題です。ZOZOTOWNではタイムセール、ショップ限定クーポン、抽選プレゼントなどのキャンペーンを期間限定で実施しています。このキャンペーンをより際立たせるためにキャンペーンページを作成し、ホーム画面やメルマガなどを通じてお客様にお届けしています。しかし、このキャンペーンページの作成が必要になった場合、エンジニアが都度実装しており、5日程度の開発工数が発生していました。そこで、このページ作成をビジネスサイドのキャンペーン担当者ができるよう、ノーコード化するシステムを構築しました。本記事では、そこで得たヘッドレスCMSであるmicroCMSとReactに関する知見を紹介します。 はじめに 背景と課題 解決方法 microCMS(ヘッドレスCMS)の導入 microCMSを用いた管理画面とAPIの構築 キャンペーンページ表示コンテンツのReactコンポーネント化 プレビューURLの発行とプレビューページの実装 キャンペーンページのリリース 効果 まとめ 最後に 背景と課題 ZOZOTOWNのキャンペーンはパンツやシューズなどの商品カテゴリーから、特定の出店ブランドを強調するものまでさまざまな切り口でお届けしています。ZOZOTOWNは多数の商品を取り扱っているため、カテゴリーやブランドを特定し厳選した商品をお客様に提案することで、よりお買い物を楽しめるようにすることがキャンペーンの狙いです。以下にキャンペーンページのイメージを記します。 アパレル商材は訴求時期が重なるという特徴を持っています。そのため、キャンペーンを短期間に複数開催するということも珍しくありません。理想としては、こうしたキャンペーンを毎日実施し、お客様に日替わりでさまざまな切り口の商品を紹介するサイトにしたいと考えています。また、ZOZOTOWNは先日リニューアルし、コスメ専門モールの ZOZOCOSME や、ラグジュアリー&デザイナーズゾーンの ZOZOVILLA がオープンしました。このリニューアルに際し、「シューズ」「コスメ」といったカテゴリーをタブ化しました。これにより、今後はカテゴリー別でキャンペーンをたくさん行う予定です。 しかし、キャンペーンページ作成はエンジニアが個別にマークアップコーディングしています。したがって、毎日続けてキャンペーンページを公開できないのが現実です。 そこで、キャンペーンページ作成時に必要となるエンジニアの作業工数を削減する方法を検討しました。その結果、ビジネスサイドのキャンペーン担当者がコンテンツを編集し、キャンペーンページをノーコードで作成できるシステムを構築することをゴールとしました。 当初はZOZOTOWNの社内サイト管理システムを拡張する形で実現できないか検討を進めました。しかし、リニューアルを進める大規模プロジェクトが並行して走っており、管理システム及びバックエンドの開発リソース確保が難しいという背景がありました。そのため、今回はバックエンドシステムに一切改修を入れずにフロントエンド側だけで完結するシステムを設計しています。 解決方法 早速ですが、まず今回採用したシステム全体図を以下に示します。 以降、この構成にした理由を説明していきます。なお、図中の(1)(2)(3)の番号を説明内で利用するので、上図を適宜参照してください。 microCMS(ヘッドレスCMS)の導入 ノーコード化を実現するために、まずは非エンジニアが操作する管理画面を作成する必要があると考えました。今回の用途のみの管理画面を内製するのは合理的でないため、コンテンツ管理システム、いわゆるCMSの1つである microCMS を導入しました。 CMSといえば、WordPressを思い浮かべる方が多いでしょう。しかし、ZOZOTOWNで導入する場合はヘッドレスCMSが適当だと考えました。「ヘッドレスCMSがそもそも何か」については以下のmicroCMS公式ブログがわかりやすいのでご覧ください。 blog.microcms.io 今回、ヘッドレスCMSを選んだ理由は zozo.jp のWebサーバー上で、このキャンペーンページを動作させたかったためです。キャンペーンページは静的ページではなく、お客様のお気に入り、商品のパーソナライズなども行います。この機能をWordPressなどの別サーバーに構築した場合、バックエンドの開発工数の発生が予想されます。その点、APIで連携するヘッドレスCMSであれば、現在利用しているテクノロジーを変えることなく部分的にCMS機能を使うことが可能です。また、CSSやJavaScriptの実行環境もこれまで通りに維持できるため、フロントエンド資産の流用、連携も可能です。 なお、ヘッドレスCMSはmicroCMS以外にも、 Contentful や Strapi など多数存在します。また、WordPressもプラグインなどを駆使してヘッドレスCMSとして利用できます。しかし、今回のCMSのニーズを以下のように整理したところ、すべて満たすものはmicroCMSのみでした。 コンテンツはエンジニアではないビジネスサイドの担当者が編集するので、管理画面でメタタグなども含めたほぼすべてのコンテンツを編集可能にしたい パターン化されたコンテンツを並び替えたり、複数設定できるようにしたい 編集結果を本番と同じ見た目で確認できるプレビューページを用意したい プレビューページはリリースするキャンペーンページと同じ環境で動作させたい インフラの構築や運用作業を不要にしたい システム利用者の役割ごとに適切なコンテンツの編集権限管理がしたい 日本語対応したい 加えて、microCMSはお客様ニーズの勘所を抑えている機能を続々とリリースしている印象がありました。また、公開されているロードマップも便利そうな機能が並んでいました。このことから、今後も改善され続けるだろうという期待を込めて、microCMSを選定した側面もあります。microCMSのブログで、過去の新機能リリースをみていただけるとお分かりいただけるかもしれません。 blog.microcms.io 以上がmicroCMSを導入した理由です。 microCMSを用いた管理画面とAPIの構築 次に、システム全体図の(1)でmicroCMSが実現している管理画面とAPIの構築について説明します。 microCMSではコンテンツ入力項目の最小単位をフィールドと呼びます。フィールドには次のような種類のデータ形式 1 が設定できます。 これらのフィールドを組み合わせて管理画面を構築していきます。今回作成するキャンペーンページのコンテンツモデルは先述の通り、コンテンツを並び替えたり、複数設定できる柔軟なものである必要があります。microCMSには繰り返しフィールドとカスタムフィールドと呼ばれる機能があり、それらを利用することで実現可能です。 この機能についてはmicroCMSのブログ、「microCMSのカスタムフィールドを使ってランディングページを作ろう」で詳しく解説されています。また、サービスの作成や管理画面の操作方法などの基本的な解説は、公式ブログや microCMS の公式ドキュメント に委ね、省略します。実際にドキュメントを読みながら操作したところ、つまずく点は特にありませんでした。 blog.microcms.io document.microcms.io 最終的にキャンペーンページのコンテンツモデル(APIスキーマ)の設定は以下の形式になります。 また、上記の設定から構築される管理画面は以下の通りです。 この管理画面上でそれぞれのコンテンツを入力すると、JSON APIエンドポイントが作成されます。 例えば、本記事の冒頭で挙げたキャンペーンページのイメージの場合、以下のJSONを返します。 { " id ": " sample_campaign ", // UUIDが発行される。手動で変更も可能 " createdAt ": " 2020-12-31T15:00:00.000Z ", // microCMSの時刻表記形式は ISO8601 " updatedAt ": " 2020-12-31T15:00:00.000Z ", " publishedAt ": " 2020-12-31T15:00:00.000Z ", " revisedAt ": " 2020-12-31T15:00:00.000Z ", " managedTitle ": " (テスト作成中)キャンペーンページサンプル ", " displays ": [ // 「フィールドを追加」をクリックするとカスタムフィールドを選ぶモーダルが掲出する。コンテンツを選べば入力フォームが現れ編集ができる。(繰り返しフィールド) { " fieldId ": " mainVisual ", // カスタムフィールドには設定したIDが自動付与される " title ": " Brand DAY ", " lead ": " Brand の 2日間限定のクーポン&タイムセール開催中! ", " backgroundImagePC ": { " url ": " https://images.microcms-assets.io/assets/**/pc_mv.jpg ", " height ": 1000 , " width ": 2560 } , " backgroundImageSP ": { " url ": " https://images.microcms-assets.io/assets/**/sp_mv.jpg ", " height ": 750 , " width ": 750 } } , { " fieldId ": " favoriteGoods ", " allShopID ": 0 , // お客様のお気に入り商品を取得する内部APIのリクエストに必要なパラメーターショップID " menShopID ": 1 , // お客様の性別が判別される場合は別のショップIDを設定できるように設定 " womenShopID ": 2 , " kidsShopID ": 3 , } , { " fieldId ": " searchMenu ", " title ": " 人気カテゴリー ", " searchMenuItems ": [ { " fieldId ": " searchMenuItem ", " title ": " Tシャツ ", " url ": " /search/xxxx " } , { " fieldId ": " searchMenuItem ", " title ": " ボトムス ", " url ": " /search/xxxx " } , ... ] } , { " fieldId ": " goodsCatalog ", " tagID ": 0 , // キャンペーンごとにtagID社内管理ツールを用いて商品をタグで紐付けをすることができる。内部WebAPIのリクエストパラメーターに用いる " isCoupon ": true , // クーポン商品のみ絞り込むかのフラグ。内部WebAPIのリクエストパラメーターに用いる " title ": " スペシャルクーポン ", " subTitle ": " 最大¥1,000分のクーポン発行中 ", " url ": "/ search / xxxx " // 「すべてをアイテムをみる」の遷移先のリンク } , ] , " meta ": { // HTMLのメタタグ関連の設定 " fieldId ": " meta ", " title ": " メタタイトル ", " description ": " メタ詳細 " } , " campaignDate ": { // キャンペーンの期間設定、これに応じてページの公開・非公開を制御する " fieldId ": " campaignDate ", " startDate ": " 2020-12-31T15:00:00.000Z ", " endDate ": " 2021-01-01T15:00:00.000Z " } } このようにCMSのコンテンツはすべてJSON APIで取得できるので、さまざなプログラミング言語や環境から呼び出すことができます。主に利用するのはコンテンツIDからコンテンツ情報を取得するAPIと、エンドポイントのコンテンツすべてを配列で取得するAPIの2つです。読み込みだけではなく書き込みも可能なので、他システムとの連携も柔軟にできるでしょう。より詳細は以下のAPIドキュメントを参照ください。 document.microcms.io キャンペーンページ表示コンテンツのReactコンポーネント化 次に、APIのレスポンスの中でも、ページに表示させるコンテンツ部分を説明します。先ほどのJSONの中にある displays という配列に注目してください。 " displays ": [ { " fieldId ": " mainVisual ", ... } , { " fieldId ": " favoriteGoods ", ... } , { " fieldId ": " searchMenu ", ... } , { " fieldId ": " goodsCatalog ", ... } ] この displays は 繰り返しフィールド を利用しています。この機能で表示コンテンツを並び替えたり、複数設定できるような操作を可能としています。フィールドとUIコンポーネントを1対1で対応させ、これらを組み合わせることでページを作成します。 ここで紹介している mainVisual , favoriteGoods , searchMenu , goodsCatalog の他にも計14点のコンポーネントを定義しました。これらのフィールドを組み合わせることで多様なキャンペーンページの作成が可能です。 ソースコード上では、これらをReactコンポーネントで定義しています。また、フィールドはTypeScriptで型定義しているので、型安全にコンポーネントを管理できます。例えば、 mainVisual フィールドであれば、以下のようなReactコンポーネントと型定義をセットで記述します。 // microCMSで定義できるフィールドを定義 interface MicroCMSField { text: string textArea: string image: { url: string height: string width: string } ... } interface MicroCMSCustomField < T , U > { fieldId: T } & Partial < U > // microCMSで定義したカスタムフィールドのIDを定義 const CUSTOM_FIELD = { mainVisual: 'mainVisual' , ... } as const type MainVisualField = MicroCMSCustomField < typeof CUSTOM_FIELD.mainVisual , { title: MicroCMSField [ 'text' ] lead: MicroCMSField [ 'textArea' ] backgroundImagePC: MicroCMSField [ 'image' ] backgroundImageSP: MicroCMSField [ 'image' ] } > interface Props { field: MainVisualField device: Device } import React , { FC } from 'react' export const MainVisual: FC < Props > = ( { field , device } ) => { const { title lead backgroundImagePC backgroundImageSP } = field const isPC = device === 'pc' return ( < section > < Title > { title } < /Title > < Lead > { lead } < /Lead > { isPC ? ( < BackgroundImagePC src = { backgroundImagePC } / > ) : ( < BackgroundImageSP src = { backgroundImageSP } / > ) } < /section > ) } また、 mainVisual のフィールドはmicroCMSの設定画面では以下のように定義しています。 このフィールド変更時に型定義も変更するようにしています。イメージとしてはRDBのスキーマ更新に近いかもしれません。フィールドとコンポーネント定義を対応させることで、フィールド変更時、ソースコードに不整合が発生しないか型検証が可能です。この検証により、型の不整合を未然に防ぎ、コンポーネントを安全に改修できました。 システム全体図の(1)microCMSの説明は以上です。 プレビューURLの発行とプレビューページの実装 次にCMS上の編集結果を確認するプレビューページについて説明します。 システム全体図の(2)プレビューページの部分です。microCMSのメニューに「画面プレビュー」というボタンがあります。このボタンを押した際の遷移先URLをどのような形式で発行するかを、以下のように設定できます。 コンテンツ編集者はボタンからプレビューページに遷移できます。 遷移先のプレビューページは関係者のみが閲覧可能なパイロット環境に構築しています。このパイロット環境は zozo.jp 本番環境と同じデータベースに接続しています。そのため、この環境で内部APIをリクエストすれば、本番と同じ商品データを取得ができます。これはキャンペーンで紹介したい実商品データなどを取得してプレビューしたかった事情もあります。 これにより、ビジネスサイドのキャンペーン担当者は公開されるページと商品情報なども含めて全く同一の見た目でプレビュー確認できる状態が実現できました。 プレビューページと公開されるページの見た目は同じです。しかし、実装の中身は異なります。プレビューページでは閲覧の度にmicroCMSのAPIをリクエストし、そのレスポンスからDOMを生成しています。いわゆるCSR(クライアントサイドレンダリング)と呼ばれるレンダリング手法です。一方、公開されるページは、静的なマークアップに変換してリリースする形を取りました。以降、この理由を説明します。 Webページのレンダリングに関する説明は、以下の記事が参考になります。 developers.google.com この記事でも、以下のようにお客様体験を良くしたい場合はSSRか静的レンダリングが推奨されています。 2 かいつまんで言うと、私たちは開発者が完全なリハイドレーションの上で、サーバーレンダリングまたは静的レンダリングを検討することを勧めるでしょう。 また、お客様体験の向上以外にも、CSRを採用しない理由に通信コストの問題があります。CSRではmicroCMSに都度データフェッチをするため、データ転送が発生します。microCMSはデータ転送量に応じた従量課金制なため、コストの観点からもCSRは望ましくありませんでした。 では、本番にリリースするページをどのように静的なマークアップに変換しているのかを次節で説明します。 キャンペーンページのリリース 本番にリリースするページについて説明します。システム全体図の(3)に該当します。 ヘッドレスCMSを用いたシステムを構築する場合、リリースするページは Next.js や Gatsby などの Jamstack に対応したフレームワークを導入し、SSGやISR 3 の機能を利用するのが一般的なセオリーと言われています。 しかし、ZOZOTOWNではテンプレートエンジンのようなミドルウェア 4 でマークアップを記述しており、今回はその記法に対応させる必要がありました。したがって、今回はこのようなフレームワークを導入せず、Reactの ReactDOMServer.renderToStaticMarkup を駆使して静的なマークアップに変換するCLIツールをNode.jsで実装しました。 5 このツールを社内ではjsx2markupと呼んでいます。jsx2markupにより、プレビューページとリリースページはレンダリング手法が異なっていても、同じJSXのコードベースを用いることが可能になります。 jsx2markupは、以下のようなコマンドを叩くことでリリース用のマークアップファイルを生成し、リリースします。 6 ts-node --files -r tsconfig-paths/register ./jsx2markup --endpointName= " microCMSのエンドポイント名 " --contentID= " microCMSの管理画面で設定したコンテンツID " jsx2markupの詳細は省きますが、先ほど言及したテンプレート記法に変換する処理など、ZOZOTOWNの環境に対応させるためのさまざまな処理をしています。加えて、画像URLをmicroCMSのものからZOZOTOWNで普段利用している画像サーバーのURLに変更する処理なども行っています。 この変換は以下のような関数で記述しました。 import path from "path" ; const getURLBasename = ( url: string ) : string => path.basename (new URL ( url ) .pathname ); // APIのレスポンスの型をジェネリクスで渡す export const transformFromCMSImages = < T >( { contents , targetDirectory , } : { contents: T ; targetDirectory: string ; } ) : T => { const microCMSImageUrlRegex = /https?:\/\/images.microcms-assets.io[-_.!~*\\'()a-zA-Z0-9;\\/?:\\@&=+\\$,%#\u3000-\u30FE\u4E00-\u9FA0\uFF01-\uFFE3]+/gm ; const stringContents = JSON .stringify ( contents ); const matches = stringContents.match ( microCMSImageUrlRegex ) || [] ; const microCMSImageUrls = Array . from( matches ); return JSON .parse ( microCMSImageUrls.reduce ( ( acc , _ , i ) => acc.replace ( microCMSImageUrls [ i ] , ` ${ targetDirectory } / ${ getURLBasename(microCMSImageUrls[i]) } ` ), stringContents ) ); } ; この処理は、画像をmicroCMSによってアップロードされたものではなく、社内で利用している画像サーバーにホスティングしたかったため必要でした。このようなニーズは、ZOZOTOWN以外でもあり得る要求だと思うので、参考になれば幸いです。 リリースは、基本的にはエンジニアがコマンドを叩くだけです。一手間かかりますが、静的なマークアップに変換することで別のメリットも得られました。 そのメリットとは、マークアップに追加の変更を加えることができる点です。なぜならば、少しのデザイン変更を加えることで、多種多様な出店ブランドの世界観やキャンペーンの訴求力を高められることがあるからです。こうしたケースへの対応も、完全に自動化してしまうと対応が困難になります。しかし、静的なマークアップに変換してしまえば、変換後のマークアップに変更を加えてリリースすることで対応可能です。そのため、リリースはあえて自動化していません。 以上で構築した環境の説明は終わります。 効果 ソフトウェアだけ作っても運用がうまくされなければ意味がありません。このキャンペーンページの場合もオペレーションを含めて考えなければいけませんでした。 そのため、スムーズな運用ができるようにビジネスサイドと定期的に議論し、CMSのマニュアル作成やキャンペーンの実施フローなどもこれを機に見直しました。 その結果、4月のシステム導入から、本記事を公開した5月14日に至るまでに、8個のページをリリースできました。これは昨年比で2倍の数です。特にゴールデンウィーク中は毎日キャンペーンを実施し、5個のページをリリースできました。 これまでの仕組みでは短期間に複数のページをマークアップすることが困難でしたが、その課題を解決でき、有用なシステムを作れたと手応えを感じています。 また、エンジニアではないビジネスサイドの担当者でも、CMS上で編集しながらプレビュー確認が行えるようになったため、デザイナーの工数削減やページの手戻りが発生しづらくなったという効果もありました。 今後もシステムや業務フローを洗練させ、キャンペーン数を増やし、お客様にお買い物を楽しんでいただけるようなサービスにしていきます。 まとめ 今回はZOZOTOWNのキャンペーンページ作成をキャンペーン担当者ができるよう、ノーコード化するシステムの構築手法、そこで得たmicroCMSやReactに関する知見や効果について紹介しました。 ZOZOTOWNは、JavaScriptに関しては ES5 , jQuery から React , TypeScript に移行中です。今後はさらにフロントエンドのWebサーバーのリプレイスも予定しています。 今回のシステムも、そのリプレイスを見据え、サーバー移行しやすいように設計しました。リプレイスで本システムにも変化がありましたら、またご紹介します。 最後に 私はZOZOTOWNを担当するエンジニアになって1年が経過しましたが、その独特なシステムにいまだ衝撃を受ける毎日です。 約16年前に誕生し、凄まじい勢いで成長してきたZOZOTOWNは、その当時としては優れたミドルウェアや技術で構築されており、現在の規模までスケールさせた先輩スタッフには尊敬の念しかありません。 しかし、これらの技術は進化の激しいソフトウェア開発の世界では、現在ではいわゆる技術的負債と呼ばれるものとなり、開発速度を鈍化させる要因の1つになっていることは否めません。今回作ったシステムも、その負債の制約がなければ、もっと別のやり方があったでしょう。しかし、これを私はネガティブには捉えていません。なぜならば、ZOZOTOWNは技術的な改善により、まだまだ伸び代があるということを意味しているからです。 ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、下記のリンクからぜひご応募ください。 corp.zozo.com また、ZOZOTOWNのフロントエンドエンジニアに興味がある方は 私 や 森口 にDMをいただいても構いません。よろしくお願いします! 記事を執筆した2021年5月時点の情報です。また、料金プランによって選択できるデータ形式は異なります。 ↩ Webは進化し続けるので2021年現在でも同じ結論になるとは限りません。しかし、検証コストやサーバーに改修を入れられない都合上、プレビューページと実際にお客様に届けるページのレンダリング手法を別にすることは、システムの構想段階から想定していました。 ↩ Jamstack, SSG, ISRについてはご存知ない方は こちらの記事 が参考になります。 ↩ Rubyにおける Haml 、JavaScriptにおける Pug のようなもので、VBScriptで利用できるマークアップ記法です。 ↩ 余談ですが、Reactはフレームワークというよりシンプルなライブラリであろうとする思想があるため、このような小回りが効くが強力なAPIがある点は素晴らしいと改めて思いました。 ↩ tsconfig-paths/register を用いることで、このスクリプトとクライアントサイドのTypeScriptの設定ファイルを共通化できます。この説明のために、本記事ではあえてコマンド上に露出させていますが、実際はnpm scriptsでエイリアスを当てています。 ↩
こんにちは、MA部でエンジニアをしている田島です。 以前に弊社の塩崎が「Amazon AuroraのデータをリアルタイムにGoogle BigQueryに連携してみた」という発表を行いました。 こちらの発表では、Amazon Aurora MySQLのデータをGoogle BigQueryへリアルタイムにデータ連携する方法を紹介しています。リアルタイムデータ連携を実現するために、Aurora MySQLをレプリケーションソースとしてGoogle Cloud SQLへレプリケーションします。そして、BigQueryのFederated Query機能を利用してリアルタイムにデータを参照できるようにしています。 本記事ではその中の、Aurora MySQLからCloud SQLへのレプリケーション部分にフォーカスします。Aurora MySQLがマネージドサービスだからこそ発生する大きな注意ポイントを2つ紹介します。 以降、以下の2つの注意ポイントを説明します。 Aurora MySQLにおけるバイナリログの保持期間の設定 Aurora MySQLのCollation 目次 目次 リアルタイムデータ連携基盤 リアルタイムデータ連携基盤の全体構成 復習「MySQLのレプリケーション」 バイナリログとリレーログ バイナリログのローテーション GTID レプリケーションの確認方法 Aurora MySQLからCloud SQLへのレプリケーション手順 1. Aurora MySQLの設定 2.〜3. Cloud SQLの構築 Aurora MySQLからCloud SQLへのレプリケーション構築における注意すべき2つのポイント (1)Aurora MySQLにおけるバイナリログの保持期間の設定 発生した問題 結論 再現実験 実験準備 ログの解読 実験結果 回避方法 (2)Aurora MySQLのCollation MySQLのCollation 発生した問題 調査(1)メインDBのCollationの設定確認 調査(2)binlogの確認 調査(3)Charset ID #255の確認 調査(4)クライアント側でCollationを指定して再現実験 調査(5)session.collation_connectionが255にセットされた原因の調査 結論 回避方法 まとめ リアルタイムデータ連携基盤 冒頭でも紹介しましたが、弊社のデータ基盤の一部では、Aurora MySQLのデータをGoogle BigQueryへリアルタイムにデータ連携をしてます。以前の発表内容をご覧になられていない方のために、改めてその概要を紹介します。発表または発表資料をご覧になられた方は読み飛ばして頂いて問題ありません。 リアルタイムデータ連携基盤の全体構成 以下がAurora MySQLからGoogle BigQueryへリアルタイムデータ連携をするための全体構成です。 前述の通り、Aurora MySQLをレプリケーションソースとしCloud SQLへレプリケーションします。これにより、Aurora MySQLのデータがリアルタイムにCloud SQLへ連携されます。そして、BigQueryからCloud SQLへFederated Queryという機能を利用することでCloud SQLのデータをBigQueryから参照できます。 以上のようにしてAurora MySQLのデータをBigQueryからリアルタイムに参照することを可能としました。 その他の構成の特徴として、AWS-GCP間のインターナル通信を実現するために、弊社のオンプレ環境を挟み専用線を用いた通信を利用しています。また、冗長構成等も行っています。そのような全体構成の詳細は、改めて別の記事で紹介する予定です。 復習「MySQLのレプリケーション」 まずはMySQLのレプリケーションについて復習します。ここでは、レプリケーションされるサーバーを「メインDB」、レプリケーションするサーバーを「レプリカDB」と呼ぶこととします。本記事では、Aurora MySQLからGoogle Cloud SQLへのレプリケーションにおいて必要となる部分のみを抜粋して説明します。より細かくレプリケーションについて学びたい場合は以下の記事が非常に参考になります。 qiita.com バイナリログとリレーログ MySQLのレプリケーションはバイナリログと呼ばれるものを利用して実現します。 メインDBのすべての変更はバイナリログに保存されます。それらのバイナリログに書かれた変更点を別のDBで再現することにより、メインDBで行われた変更を追随できます。 続いて、保存されたバイナリログをレプリカDBに送ります。メインDBから送られてきたバイナリログをレプリカDBではリレーログと呼びます。レプリカDBはリレーログに書かれた変更点を取り込むことによってメインDBで起こった変更に追随します。こうすることにより、レプリカDBはメインDBと同期が取れた状態となります。 バイナリログのローテーション バイナリログのファイル名は以下のように「 プレフィックス.インデックス 」の形式を取ります。自前でMySQLを運用している場合は「 --log-bin-index[=file_name] 」オプションを利用することでファイル名のプレフィックスを変更できます。Amazon RDSを利用している場合は以下のような「MySQL-bin-changelog」がプレフィックスに利用されます。 mysql-bin-changelog.000001 また、バイナリログは以下のコマンドを利用することで確認可能です。実際に検証用のAurora MySQLで実行した結果が以下の通りです。 mysql> SHOW BINARY LOGS ; +----------------------------+-----------+ | Log_name | File_size | +----------------------------+-----------+ | mysql-bin-changelog. 003382 | 7335768 | | mysql-bin-changelog. 003383 | 194 | | mysql-bin-changelog. 003384 | 17682649 | +----------------------------+-----------+ ここからわかるように、MySQL-bin-changelogのインデックスが「003382」から始まっており、「000001〜003381」が存在していません。これはバイナリログがローテーションされ、古いバイナリログが定期的に消されていることによります。 なお、MySQLにおいてバイナリログがローテーションされるタイミングは以下のように定義されています。 DB再起動時 max_binlog_sizeを超えたとき RDSの場合はフェイルオーバー時も また、バイナリログは以下のオプションを設定することで、定期的に削除できます。 expire_logs_days オプションの詳細に関しては以下のドキュメントをご参照ください。 dev.mysql.com また、Aurora MySQLでは、以下の例のような特別なパラメータを利用することでバイナリログの削除タイミングを設定可能です。 CALL mysql.rds_set_configuration( 'binlog retention hours' , 24 ); こちらも詳しくは以下のドキュメントをご参照ください。 aws.amazon.com このように、バイナリログファイルはローテーションされ、古いログが削除されます。そのため、初めてレプリケーションを行う場合、バイナリログを利用するだけではメインDBの状態をレプリカDBに再現することはできません。 そこで、最初にメインDBのダンプを取得し、レプリカでそれをロードします。これによりバイナリログが存在する期間までの状態をレプリカで再現できます。あとは、その続きからバイナリログを利用して変更に追随することでメインDBの状態を再現できます。 GTID 先程の説明で「メインDBのダンプを取得し、レプリカでそれをロード」し「その続きからバイナリログを利用して変更に追随することでメインDBの状態を再現できます」と紹介しました。 しかし、どのように「その続きから」をMySQLは判断するのでしょうか。MySQLがどこまでの変更が反映されたのかを判断するためのIDとしてGTIDというものが存在します。 GTIDは以下のような形式のIDです。 b340ea24-7307-34f8-afac-7cabb90c910e:1 b340ea24-7307-34f8-afac-7cabb90c910e:2 b340ea24-7307-34f8-afac-7cabb90c910e:3 以下のコマンドを利用することで人間が読める形でバイナリログを取得できます。 SET @@SESSION.GTID_NEXT= 'b340ea24-7307-34f8-afac-7cabb90c910e:38'/*!*/; とGTIDが利用されていることがわかります。 mysqlbinlog --read-from-remote-server --host=host_name --port=3306 --user username --password= ' xxxxxxxxxxx ' -v mysql-bin-changelog. 000001 > binlog. 000001 $ cat binlog. 000001 /*! 50530 SET @@ SESSION.PSEUDO_SLAVE_MODE = 1 */; /*! 50003 SET @ OLD_COMPLETION_TYPE =@@ COMPLETION_TYPE,COMPLETION_TYPE = 0 */; DELIMITER /*!*/; # at 4 #210121 6:03:16 server id 1678319257 end_log_pos 123 CRC32 0x828ddba9 Start: binlog v 4, server v 5.7.12-log created 210121 6:03:16 at startup ROLLBACK/*!*/; BINLOG ' JBkJYA+ZHglkdwAAAHsAAAAAAAQANS43LjEyLWxvZwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAkGQlgEzgNAAgAEgAEBAQEEgAAXwAEGggAAAAICAgCAAAACgoKKioAEjQA AanbjYI= ' /*!*/; # at 123 #210121 6:03:16 server id 1678319257 end_log_pos 194 CRC32 0x00f6490d Previous-GTIDs # b340ea24-7307-34f8-afac-7cabb90c910e:1-37 # at 194 #210209 9:50:29 server id 1678319257 end_log_pos 259 CRC32 0x90246c84 GTID last_committed=0 sequence_number=1 rbr_only=no SET @@ SESSION.GTID_NEXT = ' b340ea24-7307-34f8-afac-7cabb90c910e:38 ' /*!*/; # at 259 #210209 9:50:29 server id 1678319257 end_log_pos 331 CRC32 0x6ef666ab Query thread_id=14 exec_time=0 error_code=0 SET TIMESTAMP = 1612864229 /*!*/; SET @@ session.pseudo_thread_id = 14 /*!*/; SET @@ session.foreign_key_checks = 1 , @@ session.sql_auto_is_null = 0 , @@ session.unique_checks = 1 , @@ session.autocommit = 1 /*!*/; SET @@ session.sql_mode = 0 /*!*/; SET @@ session.auto_increment_increment = 1 , @@ session.auto_increment_offset = 1 /*!*/; /*!\C utf8 *//*!*/; SET @@ session.character_set_client = 33,@@session.collation_connection = 33,@@session.collation_server = 8 /*!*/; SET @@ session.lc_time_names = 0 /*!*/; SET @@ session.collation_database =DEFAULT/*!*/; ここではGTIDを利用したレプリケーションを紹介しましたが、GTIDを使わない方法も存在します。また、GTIDはMySQL 5.6以上のバージョンでないと利用できません。今回はGTIDを利用したレプリケーションを前提とするため詳細は省略します。 レプリケーションの確認方法 レプリケーションの状態を確認するためには以下のコマンドを利用します。 SHOW SLAVE STATUS; 以下に示すのは、Aurora MySQLからレプリケーションした先のレプリカDBであるCloud SQLにおいて実行した結果です。レプリケーションが正常に動作している場合、以下のような出力となります。 mysql> SHOW SLAVE STATUS \G *************************** 1 . row *************************** Slave_IO_State: Waiting for master to send event Master_Host: main.host.name Master_User: main_host_user Master_Port: 3306 Connect_Retry: 60 Master_Log_File: mysql-bin-changelog. 000011 Read_Master_Log_Pos: 42199452 Relay_Log_File: relay-log. 000006 Relay_Log_Pos: 963 Relay_Master_Log_File: mysql-bin-changelog. 000011 Slave_IO_Running: Yes Slave_SQL_Running: Yes Replicate_Do_DB: Replicate_Ignore_DB: Replicate_Do_Table: Replicate_Ignore_Table: Replicate_Wild_Do_Table: Replicate_Wild_Ignore_Table: mysql.% Last_Errno: 0 Last_Error: Skip_Counter: 0 Exec_Master_Log_Pos: 42199452 Relay_Log_Space: 1455 Until_Condition: None Until_Log_File: Until_Log_Pos: 0 Master_SSL_Allowed: No Master_SSL_CA_File: Master_SSL_CA_Path: Master_SSL_Cert: Master_SSL_Cipher: Master_SSL_Key: Seconds_Behind_Master: 0 Master_SSL_Verify_Server_Cert: No Last_IO_Errno: 0 Last_IO_Error: Last_SQL_Errno: 0 Last_SQL_Error: Replicate_Ignore_Server_Ids: Master_Server_Id: 1678319257 Master_UUID: a9168178-6d44 -3109 -b13c-06fde20ea6cf Master_Info_File: mysql.slave_master_info SQL_Delay: 0 SQL_Remaining_Delay: NULL Slave_SQL_Running_State: Slave has read all relay log; waiting for more updates Master_Retry_Count: 86400 Master_Bind: Last_IO_Error_Timestamp: Last_SQL_Error_Timestamp: Master_SSL_Crl: Master_SSL_Crlpath: Retrieved_Gtid_Set: a9168178-6d44 -3109 -b13c-06fde20ea6cf: 228240-228242 Executed_Gtid_Set: a9168178-6d44 -3109 -b13c-06fde20ea6cf: 1-228242 Auto_Position: 1 Replicate_Rewrite_DB: Channel_Name: Master_TLS_Version: Master_public_key_path: Get_master_public_key: 0 Network_Namespace: 1 row in set ( 0.15 sec) 出力結果で、以下のように Slave_IO_Running と Slave_SQL_Running がYesになっていれば、正常にレプリケーションが動作していることがわかります。 Slave_IO_Running: Yes Slave_SQL_Running: Yes また、バイナリログやリレーログに関するパラメータが表示されていることもわかります。 Master_Log_File: mysql-bin-changelog.000011 Read_Master_Log_Pos: 42199452 Relay_Log_File: relay-log.000006 Relay_Log_Pos: 963 Relay_Master_Log_File: mysql-bin-changelog.000011 GTIDに関するパラメータも表示されていて以下のような意味があります。 Retrieved_Gtid_Set: メインDBから受けっとって保持しているトランザクション。上記の場合 GTID 228240 〜 228242を保持している。1 〜 228239 を含むファイルは既に削除済み。 Executed_Gtid_Set: レプリカDBで既に実行したトランザクションを表す。上記の場合GTID 1 〜 228242 までレプリカDBに反映済み。 一方で、レプリケーションでエラー等が発生した場合は以下のような出力となります。 mysql> SHOW SLAVE STATUS \G *************************** 1 . row *************************** Slave_IO_State: Master_Host: main.host.name Master_User: main_host_user Master_Port: 3306 Connect_Retry: 60 Master_Log_File: Read_Master_Log_Pos: 4 Relay_Log_File: relay-log. 000002 Relay_Log_Pos: 4 Relay_Master_Log_File: Slave_IO_Running: No Slave_SQL_Running: Yes Replicate_Do_DB: Replicate_Ignore_DB: Replicate_Do_Table: Replicate_Ignore_Table: Replicate_Wild_Do_Table: Replicate_Wild_Ignore_Table: mysql.% Last_Errno: 0 Last_Error: Skip_Counter: 0 Exec_Master_Log_Pos: 0 Relay_Log_Space: 331 Until_Condition: None Until_Log_File: Until_Log_Pos: 0 Master_SSL_Allowed: No Master_SSL_CA_File: Master_SSL_CA_Path: Master_SSL_Cert: Master_SSL_Cipher: Master_SSL_Key: Seconds_Behind_Master: 0 Master_SSL_Verify_Server_Cert: No Last_IO_Errno: 1236 Last_IO_Error: Got fatal error 1236 from master when reading data from binary log: 'The slave is connecting using CHANGE MASTER TO MASTER_AUTO_POSITION = 1, but the master has purged binary logs containing GTIDs that the slave requires.' Last_SQL_Errno: 0 Last_SQL_Error: Replicate_Ignore_Server_Ids: Master_Server_Id: 1678319257 Master_UUID: a9168178-6d44 -3109 -b13c-06fde20ea6cf Master_Info_File: mysql.slave_master_info SQL_Delay: 0 SQL_Remaining_Delay: NULL Slave_SQL_Running_State: Slave has read all relay log; waiting for more updates Master_Retry_Count: 86400 Master_Bind: Last_IO_Error_Timestamp: 210209 10 : 52 : 51 Last_SQL_Error_Timestamp: Master_SSL_Crl: Master_SSL_Crlpath: Retrieved_Gtid_Set: Executed_Gtid_Set: a9168178-6d44 -3109 -b13c-06fde20ea6cf: 1-36 Auto_Position: 1 Replicate_Rewrite_DB: Channel_Name: Master_TLS_Version: 1 row in set ( 0.15 sec) Slave_IO_Running: No となり、レプリケーションが止まっていることがわかります。また、直前に発生したエラーは Last_IO_Error で確認できます。 Aurora MySQLからCloud SQLへのレプリケーション手順 以下の記事で、Aurora MySQLからCloud SQLへインターナル通信にてレプリケーションする方法をまとめています。 qiita.com 本記事ではこれらのうち、レプリケーションに関する部分のみを抜粋して改めて紹介します。 以下がレプリケーション構築の手順です。 Aurora MySQLの設定 Aurora MySQLの初期ダンプ Cloud SQLのレプリカ構築 Cloud SQLにおいてダンプしたデータをロード Cloud SQLからAurora MySQLに対してレプリケーションを開始 上記の手順において、一部補足説明をします。 1. Aurora MySQLの設定 まずはじめに、外部へのレプリケーションが可能となるようにAurora MySQLの設定をします。以下のドキュメントにその手順が紹介されています。 docs.aws.amazon.com また、以下のドキュメントに記載のある設定により、GTIDベースのレプリケーションを行うことができます。 docs.aws.amazon.com 2.〜3. Cloud SQLの構築 GCPにて以下のドキュメントに記載されている手順に従いCloud SQLを構築します。Cloud SQLにおけるレプリケーションではMySQLの初期ダンプは手動で行う方法と、自動で行う方法と両方用意されています。また、レプリカを構築するとダンプのロードからレプリケーションの開始まで自動で行ってくれます。 cloud.google.com Aurora MySQLからCloud SQLへのレプリケーション構築における注意すべき2つのポイント それではやっと本編です。Aurora MySQLからCloud SQLへレプリケーションする際の注意点を2つ紹介します。 (1)Aurora MySQLにおけるバイナリログの保持期間の設定 1つ目はAurora MySQLにおけるバイナリログの保持期間の設定の挙動についてです。 発生した問題 前述の通り、Aurora MySQLからCloud SQLへレプリケーションするには、初期ダンプを行い、そのデータをロードする必要があります。そのため、ダンプ開始からダンプのロード完了まで対象のバイナリログが残っていないと、ダンプ完了後に続きからレプリケーションを開始できなくなってしまいます。よって、バイナリログは「ダンプ時間 + ロード時間」だけ削除されずに残っている必要があります。そこで、実際の作業ではダンプ開始からダンプロード完了までに12時間くらいかかると予想し、バイナリログ保持期間を1日として作業しました。以下がその設定です。 CALL mysql.rds_set_configuration( 'binlog retention hours' , 24 ); 実際に試したところ、想定通りダンプの取得からダンプのロード完了まで合わせて12時間で完了し、設定したバイナリログの保持期間に収めることができました。しかし、初期ダンプロード後にレプリケーションを開始すると以下のエラーが発生しました。 mysql> SHOW SLAVE STATUS \G *************************** 1 . row *************************** Slave_IO_State: Master_Host: main.host.name Master_User: main_host_user Master_Port: 3306 Connect_Retry: 60 Master_Log_File: Read_Master_Log_Pos: 4 Relay_Log_File: relay-log. 000002 Relay_Log_Pos: 4 Relay_Master_Log_File: Slave_IO_Running: No Slave_SQL_Running: Yes Replicate_Do_DB: Replicate_Ignore_DB: Replicate_Do_Table: Replicate_Ignore_Table: Replicate_Wild_Do_Table: Replicate_Wild_Ignore_Table: mysql.% Last_Errno: 0 Last_Error: Skip_Counter: 0 Exec_Master_Log_Pos: 0 Relay_Log_Space: 331 Until_Condition: None Until_Log_File: Until_Log_Pos: 0 Master_SSL_Allowed: No Master_SSL_CA_File: Master_SSL_CA_Path: Master_SSL_Cert: Master_SSL_Cipher: Master_SSL_Key: Seconds_Behind_Master: 0 Master_SSL_Verify_Server_Cert: No Last_IO_Errno: 1236 Last_IO_Error: Got fatal error 1236 from master when reading data from binary log: 'The slave is connecting using CHANGE MASTER TO MASTER_AUTO_POSITION = 1, but the master has purged binary logs containing GTIDs that the slave requires.' Last_SQL_Errno: 0 Last_SQL_Error: Replicate_Ignore_Server_Ids: Master_Server_Id: 117581704 Master_UUID: b340ea24 -7307 -34f8-afac-7cabb90c910e Master_Info_File: mysql.slave_master_info SQL_Delay: 0 SQL_Remaining_Delay: NULL Slave_SQL_Running_State: Slave has read all relay log; waiting for more updates Master_Retry_Count: 86400 Master_Bind: Last_IO_Error_Timestamp: 210208 14 : 36 : 58 Last_SQL_Error_Timestamp: Master_SSL_Crl: Master_SSL_Crlpath: Retrieved_Gtid_Set: Executed_Gtid_Set: b340ea24 -7307 -34f8-afac-7cabb90c910e: 1-1024678 Auto_Position: 1 Replicate_Rewrite_DB: Channel_Name: Master_TLS_Version: 1 row in set ( 0.16 sec) 上記のエラーは「対象のGTIDを含むバイナリログが見つからない」といったエラーでした。実際にバイナリログを確認すると、既に対象のGTIDを含むはずのバイナリログファイルがローテートされ削除されてしまっていました。なぜ、設定したバイナリログ保持期間よりも短い時間でバイナリログが消えていたのでしょうか。 結論 AWSの仕様を改めて確認すると以下のようにドキュメントに記載されています。 aws.amazon.com この仕様を読んだ際には、最新のバイナリログ書き込みから、設定した保持期間分のバイナリログが保持されると考えていました。しかし、実際にはバイナリログの保持期間は「ログのファイルが生成されたタイミング」から指定した期間バイナリログが保持されるということが確認できました。そのため、対象バイナリログファイルがローテートされたタイミングで既にファイル作成時から保持期間を過ぎていた場合、即座にファイルが削除されてしまいます。 再現実験 実際にその挙動を実験で再現させました。 実験準備 まず初めに、3時間でbinlogが削除されるように設定します。 CALL mysql.rds_set_configuration( 'binlog retention hours' , 3 ); 続いて以下のようなスクリプトを作成しました。 定期的にデータをinsertするスクリプト while true do sleep 1 mysql -h host_name -u root -Dtest -pxxxxx -e " insert into user values (0, 'xxx'); " done 15秒ごとにGTIDの状態を取得するスクリプト while true do date mysql -h host_name -u root -Dtest -pxxxxx -e " SHOW GLOBAL VARIABLES LIKE '%gtid%'; " mysql -h host_name -u root -Dtest -pxxxxx -e " SHOW MASTER STATUS \\ G " mysql -h host_name -u root -Dtest -pxxxxx -e " SHOW BINARY LOGS \\ G " sleep 15 done 以上のスクリプトを数時間実行し、そのログを解析します。 ログの解読 上記スクリプトによって出力されるログのうち、見るべきログを説明します。 gtid_executed 最後の数字が最新のトランザクション(GTID)になっている(実行済みトランザクション) gtid_purged binlogから既に削除済みのトランザクション(GTID) これを見ることで削除されてしまったトランザクションログがわかる Log_name binlogのファイル名でトランザクションログが書き出されるファイル これを見ることでローテーションされたbinlogファイルが確認できる 実験結果 実験の結果を順に紹介します。 上記スクリプトを「Tue Feb 9 14:28:33 UTC」から実行開始 Tue Feb 9 14:28:33 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-9172 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-37 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000007 Position: 2393564 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-9172 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000007 File_size: 2393564 10時間後の「Wed Feb 10 00:28:42 UTC 2021」 Wed Feb 10 00:28:42 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-43018 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-37 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000007 Position: 11261216 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-43018 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000007 File_size: 11261216 トランザクション単位でbinlogが削除されるのであれば「gtid_purged」の値が変わっているはずです。しかし、実際には変わっていないことがわかります。よって、binlog保持期間を過ぎてもログは削除されていないことがわかりました。 「Wed Feb 10 04:08:45 UTC 2021」 binlogをローテートさせるため、 メインDBのフェイルオーバーを実施します。 「Wed Feb 10 04:18:45 UTC 2021」 Wed Feb 10 04:18:45 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-55967 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-55321 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000008 Position: 169446 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-55967 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000008 File_size: 169446 ログローテートされてから数十秒後に000007のbinlogが削除されました。ここから、ローテート済みバイナリログファイルでないと削除されないことがわかります。 「Wed Feb 10 05:02:55 UTC 2021」 再度フェイルオーバーを行い、バイナリログをローテートさせます。 「Wed Feb 10 05:27:57 UTC 2021」 Wed Feb 10 05:27:57 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-59887 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-55321 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000009 Position: 370400 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-59887 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000008 File_size: 826280 *************************** 2. row *************************** Log_name: mysql-bin-changelog.000009 File_size: 370400 フェイルオーバーから数十秒経過しても古いバイナリログ000008が削除されていないことから、保持期間は何らかの形で働いていることがわかります。 「Wed Feb 10 05:30:29 UTC 2021」 再現性を確認するために、再度フェイルオーバーを実施します。 Wed Feb 10 06:30:55 UTC 2021 Wed Feb 10 06:30:55 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-63482 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-55321 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000010 Position: 914836 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-63482 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000008 File_size: 826280 *************************** 2. row *************************** Log_name: mysql-bin-changelog.000009 File_size: 397648 *************************** 3. row *************************** Log_name: mysql-bin-changelog.000010 File_size: 914836 フェイルオーバーから数十秒経過しても000008、000009は削除されないことを確認しました。 「Wed Feb 10 07:10:26 UTC 2021」 Wed Feb 10 07:10:11 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-65733 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-55321 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000010 Position: 1504598 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-65733 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000008 File_size: 826280 *************************** 2. row *************************** Log_name: mysql-bin-changelog.000009 File_size: 397648 *************************** 3. row *************************** Log_name: mysql-bin-changelog.000010 File_size: 1504598 Wed Feb 10 07:10:26 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-65747 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-58474 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000010 Position: 1508266 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-65747 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000009 File_size: 397648 *************************** 2. row *************************** Log_name: mysql-bin-changelog.000010 File_size: 1508266 このタイミングで000008が削除されました。「Wed Feb 10 04:08:45 UTC 2021」にbinlogが生成されたので、約3時間くらい経過しています。これはバイナリログ保持期間とほぼ同じタイミングです。 「Wed Feb 10 08:05:36 UTC 2021」 Wed Feb 10 08:05:21 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-68879 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-58474 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000011 Position: 447428 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-68879 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000009 File_size: 397648 *************************** 2. row *************************** Log_name: mysql-bin-changelog.000010 File_size: 1881616 *************************** 3. row *************************** Log_name: mysql-bin-changelog.000011 File_size: 447428 Wed Feb 10 08:05:36 UTC 2021 mysql: [Warning] Using a password on the command line interface can be insecure. Variable_name Value binlog_gtid_simple_recovery ON enforce_gtid_consistency ON gtid_executed a9168178-6d44-3109-b13c-06fde20ea6cf:1-68893 gtid_executed_compression_period 1000 gtid_mode ON gtid_owned gtid_purged a9168178-6d44-3109-b13c-06fde20ea6cf:1-59991 session_track_gtids OFF mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** File: mysql-bin-changelog.000011 Position: 451358 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: a9168178-6d44-3109-b13c-06fde20ea6cf:1-68894 mysql: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Log_name: mysql-bin-changelog.000010 File_size: 1881616 *************************** 2. row *************************** Log_name: mysql-bin-changelog.000011 File_size: 451358 このタイミングで000009が削除されました。「Wed Feb 10 05:02:55 UTC 2021」から約3時間経過しているので、やはりバイナリログ保持期間とほぼ同じタイミングです。 以上の実験結果により、実際にバイナリログファイルが作成されてから指定した保持期間後に対象のバイナリログがローテートされている場合にのみ削除されることが確認・再現できました。 回避方法 上記の結果からわかるように、バイナリログファイルがログローテートされる前に保持期間を過ぎてしまった場合、ログローテートされたタイミングで即座にファイルが削除されてしまいます。 そこで、自分たちのシステムではどれくらいの期間で max_binlog_size に到達するのかを知る必要があります。これがわかったら「ダンプ取得 + ダンプロード」時間と「 max_binlog_size に到達する」時間を比較し、ログ保持期間をその大きい方の値以上に設定することでログの消失を防ぐことができます。 (2)Aurora MySQLのCollation 2つ目の注意点はAurora MySQLのCollationについてです。 MySQLのCollation MySQLはCharsetの他にCollationを指定できます。Collationは日本語では照合順序のことで文字の並び順のことです。これにより、どちらの文字の方が大きいのか小さいのかを判定します。また、文字を区別する際の役割も担っており、大文字と小文字「A」と「a」や濁点と半濁点「ば」と「ぱ」を同じ文字として認識するのかといったことはCollationによって異なります。 発生した問題 (1)の問題が解決し無事レプリケーションに成功した後、以下のエラーが発生しました。 Error 'Character set '#255' is not a compiled character set and is not specified in the '/usr/share/mysql/charsets/Index.xml' file' on query. Default database: 'database_name'. Query: 'create index idx_xxxx_yyyy on xxxx ( xxxx, yyyy)' このエラーを調べたところ Charset ID #255 がCloud SQLには存在していないというエラーでした。これは CHARACTER_SET_NAME=utf8_mb4 、 COLLATION_NAME=utf8mb4_0900_ai_ci という組み合わせでした。そして、 utf8mb4_0900_ai_ci はMySQL 8.0から導入されたことがわかりました。 調査(1)メインDBのCollationの設定確認 メインDBであるAurora MySQLではバージョン5.7を利用しているため、Collation utf8mb4_0900_ai_ci は使われないと考えていました。実際に確認すると以下のCollationしか使われていませんでした。 utf8mb4_bin utf8_general_ci これらは以下のコマンドで調査しました。 -- データベースのCollationの確認 mysql> SHOW VARIABLES LIKE 'collation_server' ; +----------------------+-------------------+ | Variable_name | Value | +----------------------+-------------------+ | collation_connection | latin1_swedish_ci | | collation_database | utf8mb4_bin | | collation_server | utf8mb4_bin | +----------------------+-------------------+ -- テーブルごとのCollationの確認 mysql> SHOW TABLE STATUS FROM データベース名 \G *************************** 1 . row *************************** Name: xxxxx Engine: InnoDB Version: 10 Row_format: Dynamic Rows : 27 Avg_row_length : 606 Data_length: 16384 Max_data_length: 0 Index_length: 16384 Data_free: 0 Auto_increment : NULL Create_time: 2021-02-12 07 : 34 : 57 Update_time: 2021-04-05 06 : 24 : 30 Check_time: NULL Collation: utf8mb4_bin Checksum : NULL Create_options: Comment : (略) -- カラムごとのCollationの確認 mysql> SELECT table_name, column_name, collation_name FROM columns WHERE table_schema= 'データベース名' AND collation_name IS NOT NULL ; +-----------------------+-----------------+-----------------+ | table_name | column_name | collation_name | +-----------------------+-----------------+-----------------+ | xxxxx | xxxxxxx | utf8mb4_bin | | yyyyy | yyyyyyy | utf8_general_ci | (略) +-----------------------+-----------------+-----------------+ 調査(2)binlogの確認 続いて、実際にどのようなbinlogが生成されたことで、このエラーが発生したのかを確認します。対象のbinlogを確認すると以下のような怪しい部分が見つかりました。 @@session.charset_client 並びに @@session.collation_connection が 255 にセットされていることが確認できます。 # at 80217401 #210212 1:17:30 server id 117581704 end_log_pos 80217607 CRC32 0x682b2d36 Query thread_id=79799 exec_time=910 error_code=0 SET TIMESTAMP=1613092650.534322/*!*/; SET @@session.sql_mode=0/*!*/; /*!\C utf8mb4 *//*!*/; SET @@session.character_set_client=255,@@session.collation_connection=255,@@session.collation_server=46/*!*/; create index xxxxxxxxxxxxxxx on xxxxxxxxxxxx (xxxxxx, yyyyyy) /*!*/; 調査(3)Charset ID #255の確認 実際にメインDBで Charset ID #255 が使われていることがわかったため、Aurora MySQLにおける Charset ID #255 がどのようになっているかを確認しました。以下のコマンドで確認できます。 mysql> SELECT * FROM information_schema.collations WHERE id = 255 ; +--------------------+--------------------+-----+------------+-------------+---------+ | COLLATION_NAME | CHARACTER_SET_NAME | ID | IS_DEFAULT | IS_COMPILED | SORTLEN | +--------------------+--------------------+-----+------------+-------------+---------+ | utf8mb4_0900_ai_ci | utf8mb4 | 255 | | Yes | 0 | +--------------------+--------------------+-----+------------+-------------+---------+ 1 row in set ( 0.00 sec) Charset ID #255 が存在し、Collationは utf8mb4_0900_ai_ci となっています。 utf8mb4_0900_ai_ci は、MySQL 8.0から導入されたCollationでした。しかし、Aurora MySQLでは5.7バージョンにおいて先行してこのCollationが使えるとわかりました。また、Cloud SQLにおいて同じように確認すると、 Charset ID #255 が存在していないことがわかります。 mysql> SELECT * FROM information_schema.collations WHERE id = 255 ; Empty set ( 0.15 sec) 調査(4)クライアント側でCollationを指定して再現実験 調査をすすめると、MySQLでは以下のコマンドでクライアント側から利用するCollationを指定可能であることがわかりました。 SET collation_connection = utf8mb4_0900_ai_ci; そこで、検証環境のメインDB側でCollationを指定した場合と指定しなかった場合の挙動を確認します。書き込み処理は実際に本番環境でエラーが発生したインデックスの作成をしています。 mysql> SHOW VARIABLES LIKE 'collation%' ; +----------------------+-------------------+ | Variable_name | Value | +----------------------+-------------------+ | collation_connection | utf8_general_ci | | collation_database | latin1_swedish_ci | | collation_server | latin1_swedish_ci | +----------------------+-------------------+ 3 rows in set ( 0.00 sec) mysql> create index idx_user_id on user (id); Query OK, 0 rows affected ( 0.61 sec) Records: 0 Duplicates: 0 Warnings: 0 mysql> SET collation_connection = utf8mb4_0900_ai_ci; Query OK, 0 rows affected ( 0.00 sec) mysql> SHOW VARIABLES LIKE 'collation%' ; +----------------------+--------------------+ | Variable_name | Value | +----------------------+--------------------+ | collation_connection | utf8mb4_0900_ai_ci | | collation_database | latin1_swedish_ci | | collation_server | latin1_swedish_ci | +----------------------+--------------------+ 3 rows in set ( 0.00 sec) mysql> create index idx_user_name on user (name); すると、以下のようなバイナリログが出力されました。 Collation指定前 # at 42198999 #210215 2:14:50 server id 1678319257 end_log_pos 42199110 CRC32 0x37819efd Query thread_id=194567 exec_time=1 error_code=0 SET TIMESTAMP=1613355290/*!*/; create index idx_user_id on user (id) /*!*/; Collation指定後 # at 42199175 #210215 2:15:16 server id 1678319257 end_log_pos 42199290 CRC32 0x328a35c9 Query thread_id=194567 exec_time=0 error_code=0 SET TIMESTAMP=1613355316/*!*/; /*!\C utf8 *//*!*/; SET @@session.character_set_client=33,@@session.collation_connection=255,@@session.collation_server=8/*!*/; create index idx_user_name on user (name) /*!*/ 以上の結果から、確かにバイナリログに @@session.collation_connection=255 がセットされていることがわかります。また、レプリケーションも同様のエラーが発生し、事象の再現に成功しました。 調査(5)session.collation_connectionが255にセットされた原因の調査 ここまでの調査により、クライアント側でCollationをセットすることで、バイナリログにもその変更が反映されることがわかりました。しかし、わざわざCollationをクライアント側でセットしていませんでした。クライアント側のCollationのデフォルト値について調査したところ、ロケールを以下のように設定した環境においてMySQL 8.0クライアントを利用するとデフォルトで utf8mb4_0900_ai_ci のCollationが使われるとわかりました。 export LANG= ja_JP.UTF-8;localedef -f UTF-8 -i ja_JP ja_JP.utf8 mysql> SHOW VARIABLES LIKE 'collation%' ; +----------------------+--------------------+ | Variable_name | Value | +----------------------+--------------------+ | collation_connection | utf8mb4_0900_ai_ci | | collation_database | utf8mb4_bin | | collation_server | utf8mb4_bin | +----------------------+--------------------+ この状態で書き込みを行うと、実際にコマンドからCollationをセットしたときと同様のバイナリログが出力されました。また、Collationの詳細な挙動は以下のドキュメントをご参照ください。 dev.mysql.com 結論 以上の調査結果から、Collation utf8mb4_0900_ai_ci が利用できるAurora MySQL環境において、Collationを指定して書き込みを行うことでバイナリログに以下が書き出されることがわかりました。 @@session.collation_connection=255 そして、これをCloud SQL側で読み取ると対象のCollationが存在しないためエラーが発生します。また、Collationを意図的に指定しなくても、MySQL 8.0クライアントを利用しロケールを適切にセットするとデフォルトで utf8mb4_0900_ai_ci が利用されることもわかりました。 回避方法 回避方法として、クライアント側で必ずCollationを確認することは可能ですが、意図せずCollationの設定にミスがあるとそのタイミングでレプリケーションが止まってしまいます。そのため、システム側で解決する必要があります。 最初にCloud SQLへ対象のCollationを追加することを考えました。しかし、Cloud SQLの特性上Collationの追加はできないことがわかりました。 そこで、Cloud SQL側のMySQLのバージョンを8.0とすることで回避しました。Collation以外の部分で非互換な部分はありますが、現在の運用方法では特にバージョン違いが原因による問題は発生していません。実際にこのような構成を採用する場合は事前に使っている機能に互換性があるか確認することをおすすめします。 まとめ 本記事では、Auroara MySQLからCloud SQLへのレプリケーションにおいて注意すべき点を2点紹介しました。 弊社では、エラーを1つずつ調査し、妥協せずに解決まで導き出せるエンジニアを募集しています。興味がありましたら以下のリンクからご応募ください。 tech.zozo.com
はじめに ブランドソリューション開発部プロダクト開発チームの木目沢です。 Fulfillment by ZOZO (以下、FBZ)で提供しているAPIの開発に携わっています。以前「 FBZにおけるドメイン駆動設計(DDD)とサーバーレスアーキテクチャを組み合わせた設計戦術 」という記事を公開しました。そこでは、AWS Lambdaを中心としたサーバーレスアーキテクチャを採用していること、ドメイン駆動設計でAWSのサービス処理とビジネス処理を分離していることをご紹介しました。 FBZはリリース前の設定時にはJavaも検討していました。しかし、結果として採用を見送ることにしました。その理由とリリースから4年が経過した今、改めてJavaに関して調査した結果を本記事ではご紹介します。 JavaではなくPythonを選択した理由 FBZの設計をしていた当時、Lambdaで使用可能な言語は、Node.js、Python、Javaの3つでした。FBZは最終的にPythonを選択し実装されていますが、設計の途中までは以下の理由からJavaを最有力候補として考えていました。 ビジネスロジックが複雑でドメイン駆動で設計していくことを前提としていたため、型のある言語が必要だった Scalaの実装経験を持つメンバーが多く、Javaへの親和性が高かった AWS Lambdaで使用できる言語かつ、Scala同様のJVM言語であるJavaが一番扱いやすい言語だった しかし、サーバーレスアーキテクチャを検討していくなかで、以下の理由からJavaを採用することは難しいと感じるようになってきました。 AWS Lambdaはハンドラーが呼ばれる度に起動される Javaの場合、起動が許容できないほど遅い 例えば、Pythonだと起動から終了まで0.3秒程のAWS Lambdaの処理が、Javaだと約7秒かかる デプロイパッケージサイズが圧縮済みで50MB、解凍して250MBという制約がある JavaでSpring Frameworkを使うだけでこの制約を超えてしまう この2点の制約からJavaの採用を見送り、型ヒントが利用できるPythonを採用する方針にしました。 AWS Lambdaの進化 FBZリリースから4年が経ち、AWS Lambdaも進化しました。 特に以下の機能追加は「JavaでAWS Lambdaを実装できる」と十分に思わせてくれるものでした。 Lambdaレイヤー AWS Lambda本体が使用するライブラリ群を「Lambdaレイヤー」という別レイヤーで管理することが可能になりました。これにより、Springなどのライブラリを含めない状態でAWS Lambdaを利用でき、圧縮後の50MBの制約を気にする必要がなくなりました。 ただし、「AWS Lambda本体 + Lambdaレイヤーの合計が250MBの制約」は残っているため、その点は引き続き考慮する必要があります。 Lambdaカスタムランタイム あらかじめAWS側で用意されているランタイムはJava、Python、Node.js、Rubyです。しかし、それ以外のランタイムも利用できるようになりました。 AWS LambdaでJavaを利用する際のフレームワーク比較・検討 前述の2つの新機能を活かし、今回はベータ版のものも含めた3つのフレームワークでAWS Lambdaの実装を試してみました。 Spring Cloud Function フレームワークのライブラリをLambdaレイヤーに配置する Micronaut & GraalVM ネイティブアプリにコンパイルし、Lambdaカスタムランタイムを利用して動作させる Spring Native ベータ版 Spring Cloud Function Spring Cloud Function はAWS LambdaをサポートしたSpring Frameworkです。Spring MVCのようなコントローラーの代わりに、 java.util.function.Function を実装したクラスがAWS Lambdaのハンドラーとして動作します。実装の詳細は ドキュメント をご確認ください。これまでのSpring Frameworkの機能も利用できるので、Springに慣れたチームであれば容易に実装できるフレームワークです。 ところが、最低限必要なライブラリを追加するだけでjarファイルが20MB超えてしまいます。しかし、それをLambdaレイヤーを利用し、ライブラリと本体を分離して配置することで解消可能です。 Gradleのマルチプロジェクト機能を利用すると子プロジェクトを作成できます。そのため、プロダクト本体の子プロジェクトとライブラリの子プロジェクトを作成します。そして、本体側はライブラリ側を参照するように依存関係を設定するとうまく両者を管理できます。本体側でライブラリ側を参照する際には compileOnly としておくことで、ビルドファイルから除外されるので便利です。 以下はLambdaレイヤー側の build.gradle の例です。 dependencies { implementation( "org.springframework.cloud:spring-cloud-function-adapter-aws" ) implementation( "org.springframework.cloud:spring-cloud-function-web" ) implementation( "org.springframework.boot:spring-boot-starter-validation" ) implementation( "org.springframework.boot:spring-boot-starter-web" ) implementation( "com.amazonaws:aws-lambda-java-events" ) implementation( "com.amazonaws:aws-lambda-java-core" ) implementation( "com.amazonaws:aws-lambda-java-log4j" ) // other implementation } 次に、プロダクト側の build.gradle の例です。 dependencies { compileOnly project( ":layers" ) // other implementation } Lambdaレイヤーとプロダクト側のプロジェクトをそれぞれビルドし、AWS Lambdaにアップロードします。この方法で、容量の問題をある程度解決できます。 ただし、この状況では実行速度の問題がまだ残っています。それを解決する選択肢として、Lambdaカスタムランタイムを利用した新しい解決策であるMicronaut & GraalVMを紹介します。 Micronaut & GraalVM Micronaut(マイクロノート) はSpring Framework同様、フルスタックのフレームワークです。 これまでのフレームワークはDIなどにリフレクションを使用していました。リフレクションは動的にクラスやフィールドにアクセスする技術ですが、それはJavaがJavaバイトコードにコンパイルされ、JVM上で動作することを活かしたものです。しかし、Javaの起動が遅い原因の1つがこのリフレクションの処理でもあるため、Micronautではリフレクションを使用しないように設計されています。 さらに、リフレクションを使用しないため、JVMにこだわる必要もなくなり、 GraalVM を利用してネイティブアプリにコンパイルできます。実装の詳細は AWS Lambdaに焦点を合わせた公式ガイド をご確認ください。 MicronautのGradleプラグインがビルド時に圧縮まで自動的に行ってくれます。そのため、その圧縮ファイルをアップロードするだけで設定が完了できます。 アップロードする際には、以下の画像のようにカスタムアプリとして登録します。 MicronautのGradleプラグインはbootstrapファイルも内包してくれますので、「ユーザー独自のブートストラップを提供する」を選択してください。 AWS Lambda作成画面 MicronautのGradleプラグインを使うとbuildNativeLambdaタスクが追加されます。このタスクを実行することで、ネイティブアプリにビルドできます。以下は build.gradle の例です。 plugins { id( "io.micronaut.application" ) version "1.3.3" } micronaut { processing { incremental( true ) annotations( "micronaut_sample.*" ) } version = "2.3.0" runtime "lambda" } dependencies { compileOnly( "org.graalvm.nativeimage:svm" ) implementation( "io.micronaut:micronaut-validation" ) implementation( "io.micronaut:micronaut-runtime" ) implementation( "io.micronaut.aws:micronaut-function-aws" ) implementation( "io.micronaut.aws:micronaut-function-aws-custom-runtime" ) // other implementation } Micronautでネイティブアプリ化をすることで、AWS Lambdaの実行速度はかなり改善されます。しかし、今やSpring Framework以外のフレームワークを利用することに抵抗があるチームも多いかと思います。 そんな中、3月に Spring Nativeのベータ版が発表 されました。ベータ版ではありますが、Spring Nativeの検証も実施しました。 Spring Native(ベータ版) Spring NativeもMicronautと同様に、GraalVMを利用してコンパイルされます。一度JVMにコンパイルした後、AOTコンパイル(Ahead-Of-Time・事前コンパイル)する仕組みです。 Gradleプラグインも用意されています。Spring Cloud Functionの設定にプラグインを追加し、下記のように設定するだけで利用可能になります。これにより、bootBuildImageタスクが追加されます。 plugins { id "org.springframework.experimental.aot" version "0.9.1" } bootBuildImage { builder = "paketobuildpacks/builder:tiny" environment = [ "BP_NATIVE_IMAGE" : "true" ] } このGradleプラグインはDockerでビルドされるので、このプラグインを使う場合はAmazon ECRにpushして参照する必要があります。 実行可能ファイルに変換するGradleプラグインは現時点で用意されておらず、Mavenを利用する必要があります。詳細はリファレンスの 2.2 Getting started with native image Maven plugin を参考にしてください。 また、Mavenのプラグインはbootstrapファイルの用意までは実施してくれないため、自作する必要があります。 実行結果の比較・検討 各フレームワークで、起動から任意の文字列を100回ループして終了するまでの時間を計測し、比較してみました。 Spring Cloud Function Micronaut & GraalVM Spring Native 約7秒 約0.6秒 約0.6秒 冒頭でJavaを諦めた理由として、起動が遅いと述べました。Spring Cloud Functionではその認識通り、約7秒の時間を要していました。Spring Cloud Functionを単体で使う場合は、Lambda関数の暖機をおこなう Serverless WarmUp Plugin などを利用しないと実用的ではありません。 一方、Lambdaカスタムランタイムを利用してMicronautやSpring Nativeを利用することで圧倒的に所要時間が短くなりました。 MicronautやSpring Nativeの起動時間は大きく短縮できますが、ネイティブアプリにコンパイルする時間がかかります。Micronautで4分30秒程かかりました。 これを致命的と判断するかどうかで、評価が分かれてきそうです。AWS Lambdaの場合、アクセスされるたびにアプリケーションが起動されるので、起動時間の速度が非常に重要です。その点でネイティブアプリにコンパイルできるフレームワークは重宝されます。 まとめ AWS Lambdaの進化と、フレームワークの進化によって選択肢が広がっていることを実感できました。これは、アーキテクチャの検討の際にも選択肢が増え、理想のアプリケーションを実現させる武器になります。 ブランドソリューション開発部では、今後はJavaを使ったWebアプリの開発をしていくことも検討しています。システムによっては、FBZでのサーバーレスアーキテクチャの開発経験を活かして、Java + サーバーレスアーキテクチャを組み込むのも良いでしょう。 ブランドソリューション開発部では、サーバーレスアーキテクチャやドメイン駆動設計など、テクノロジーを活用しサービスを成長させたい仲間を募集中です。ご興味ある方は こちら からぜひご応募ください! tech.zozo.com
はじめに こんにちは、推薦基盤部の与謝です。ECサイトにおけるユーザの購買率向上を目指し、レコメンデーションエンジンを研究・開発しています。最近ではディープラーニングが様々な分野で飛躍的な成果を収め始めています。そのため、レコメンデーション分野でも研究が進み、精度向上に貢献し始めています。本記事では、ディープニューラルネットワーク時代のレコメンド技術について紹介します。 目次 はじめに 目次 パーソナライズレコメンドとは 深層学習より前の推薦手法 協調フィルタリング Matrix Factorization SVD(Singular Value Decomposition) Factorization Machine 深層学習を使った推薦手法 ニューラルネットワーク推薦手法に対する警鐘 Recboleプロジェクト Recboleプロジェクトを用いた各アルゴリズムの検証 General Recommendationに分類されるアルゴリズム GMF(Generalized Matrix Factorization) NCF(Neural Collaborative Filtering) NeuMF(Neural Matrix Factorization) Graph Recommendationに分類されるアルゴリズム NGCF(Neural Graph Collaborative Filtering) LightGCN(Light Graph Convolutional Network) DGCF(Disentangled Graph Collaborative Filtering) Knowledge Aware Recommendationに分類されるアルゴリズム RippleNet CKE(Collaborative Knowledge Base Embedding for Recommender Systems) Sequential Recommendationに分類されるアルゴリズム RNNRecommender Item2Vec BERT4Rec 深層学習×推薦のまとめ 短いライフサイクルで推薦を可能にするための計算時間を短縮する工夫 スパースマトリックスによるデータ量削減 Cython GPU 最後に パーソナライズレコメンドとは レコメンドエンジンとは、ECサイトやWebサイト上で、ユーザにおすすめの商品やコンテンツを表示するためのシステムです。 「新着順」や「人気順」などの汎化されたレコメンドもありますが、個々のユーザに パーソナライズ することによって、閲覧や購買を促進します。ユーザの閲覧履歴や購入履歴などから関連性のある商品やコンテンツ情報を表示させることで、サイト運営側は売り上げや閲覧数を増加させ、ユーザはより自分の気にいる商品やコンテンツを発見しやすくなります。 次章以降、このパーソナライズレコメンドについて掘り下げていきます。 深層学習より前の推薦手法 まずは深層学習より前のレコメンデーションをいくつか見ていきましょう。 協調フィルタリング Aという商品を閲覧・購入した人はBという商品も閲覧・購入した人が多いため、Aという商品を閲覧・購入した人にはBという商品を薦める。といったように、 協調フィルタリング はWebのアクセス履歴やユーザの行動履歴に基づいて商品をレコメンドする手法です。この手法は、商品情報などのコンテンツ情報の必要がない、という点がポイントです。協調フィルタリングでは、ユーザ同士またはアイテム同士のコサイン類似度を計算し、レコメンドを行います。 (引用: 協調フィルタリング入門 ) Matrix Factorization Matrix Factorization は、協調フィルタリングに対する次元削減によって、より良いレコメンドを行います。協調フィルタリングの場合、ユーザやアイテムの数が増えるとそれだけ次元が増えてしまい、計算が困難になります。これが次元の呪いと呼ばれる問題です。そのため、このような高次元のデータを扱うために、高次元データの特徴をできるだけ保持したままデータを低次元データに変換します。これが次元削減です。2008年に行われた推薦システムのコンテスト、 Netflix Prize で最も成果を上げたモデルの1つです。 (引用: Simple Matrix Factorization example on the Movielens dataset using Pyspark ) SVD(Singular Value Decomposition) SVD は特異値分解によって次元削減する手法です。特異値分解は行列の低ランク近似や擬似逆行列の計算などに使われ、特異値を成分とした対角行列を生成します。 (引用: Recommender Systems with Python — Part III: Collaborative Filtering (Singular Value Decomposition) ) Factorization Machine Factorization Machine(FM) は、Matrix Factorizationを使いやすく進化させ、より精度の高いレコメンドエンジンを作成できます。Matrix Factorizationでは、ユーザとアイテムの情報しか扱えなかったため、性別、年齢などをレコメンドエンジンの作成に用いる事ができませんでした。Factorization Machineは、それ以外の情報も扱えるため、性別、年齢を考慮できます。さらに特徴量の間で影響を与えあう相互作用(Interaction)を考慮できるので、相関関係がある特徴量も扱えます。 (引用: On Factorization Models ) 深層学習を使った推薦手法 次に深層学習を使ったレコメンデーションをいくつか見ていきましょう。 ニューラルネットワーク推薦手法に対する警鐘 RecSys 2019と2020の2年連続で、推薦システムの公平なベンチマークに向けた調査と提案に関する論文が発表されました。RecSysとは推薦システムに関するACM主催の国際学会で、この分野ではトップカンファレンスです。 Are We Really Making Much Progress? A Worrying Analysis of Recent Neural Recommendation Approaches Are We Evaluating Rigorously? Benchmarking Recommendation for Reproducible Evaluation and Fair Comparison 上記の論文では、近年流行しているニューラルネットワークベースの推薦手法に対し、有効性やロバスト性(あらゆるデータセットに対して安定したパフォーマンスを出しているか)を検証しました。論文では、多くのDNNベースの推薦手法では旧来の協調フィルタリングやBPRFM手法に勝つことができないと主張しており、DNNベースの推薦手法に疑問符が付きました。 Recboleプロジェクト レコメンドの研究コミュニティでは、レコメンデーションアルゴリズムのオープンソース実装の標準化に対する関心が高まりつつあります。包括的かつ効率的なレコメンダーシステムライブラリとして、 Recbole プロジェクトが発足されました。PyTorchを元に開発され、研究者が推奨モデルを再現・開発する支援をします。本ライブラリの特徴は 公式ページ には以下のように書かれています。 一般的で拡張可能なデータ構造 さまざまな推奨データセットのフォーマットと使用法を統一するために、一般的で拡張可能なデータ構造を設計する 包括的なベンチマークモデルとデータセット 65の一般的に使用される推奨アルゴリズムを実装し、28の推奨データセットのフォーマットされたコピーを提供する 効率的なGPUアクセラレーションによる実行 ライブラリの効率を高めるために、GPU環境で多くの調整された戦略を設計する 広範で標準的な評価プロトコル レコメンデーションアルゴリズムをテストおよび比較するために、一般的に使用される一連の評価プロトコルまたは設定をサポートする Recboleプロジェクトを用いた各アルゴリズムの検証 一般的に使用される推薦データセットの1つである MovieLens 100K Dataset に対し、Recboleに用意されている推薦アルゴリズムを実行し、実行時間やパフォーマンスを計測しました。なお、計測不能なものは除いており、数値は参考値としてご覧ください。 Model Step 秒数 recall@10 mrr@10 ndcg@10 hit@10 precision@10 BPR 53 28.66 0.2358 0.4711 0.2801 0.7646 0.1882 ConvNCF 18 68.74 0.1035 0.2341 0.1235 0.5111 0.0941 DGCF 83 417.9 0.2421 0.4787 0.2862 0.7773 0.1916 DMF 45 52.47 0.226 0.4149 0.2521 0.7678 0.1783 FISM 33 72.4 0.2237 0.4573 0.2689 0.7519 0.1777 GCMC 15 31.97 0.1835 0.3841 0.2136 0.6872 0.1419 ItemKNN 0 2.25 0.247 0.4623 0.2834 0.7847 0.1931 LightGCN 136 92.4 0.2467 0.4838 0.2895 0.7826 0.1949 LINE 85 56.52 0.2025 0.3875 0.2284 0.7243 0.1601 NAIS 18 126.54 0.2389 0.4586 0.2764 0.7741 0.1894 NeuMF 35 40.08 0.238 0.4567 0.2768 0.7678 0.191 NGCF 74 64.83 0.2476 0.4978 0.2983 0.7869 0.1994 Pop 0 1.624 0.0289 0.1244 0.0558 0.2694 0.0492 SpectralCF 26 22.44 0.1133 0.2686 0.1363 0.5578 0.1014 CFKG 13 12.88 0.1109 0.2664 0.1368 0.5408 0.1027 CKE 88 84.38 0.243 0.4813 0.2863 0.7752 0.1954 KGCN 39 51.26 0.2159 0.4401 0.2566 0.7476 0.1749 KGNNLS 39 72.5 0.2159 0.4401 0.2566 0.7476 0.1749 KTUP 60 82.16 0.1716 0.3374 0.2006 0.6808 0.1503 MKR 64 145.2 0.194 0.4016 0.2315 0.7041 0.1601 RippleNet 27 258.06 0.198 0.3873 0.2281 0.7094 0.1642 BERT4Rec 29 584.83 0.1113 0.0335 0.0513 0.1113 0.0111 FDSA 43 1130.45 0.1273 0.0403 0.0601 0.1273 0.0127 FOSSIL 41 42.06 0.1007 0.032 0.0476 0.1007 0.0101 FPMC 23 13.65 0.0838 0.0244 0.0382 0.0838 0.0084 GCSAN 17 3490.54 0.1241 0.0398 0.0591 0.1241 0.0124 GRU4Rec 33 103.96 0.1304 0.0483 0.0671 0.1304 0.013 GRU4RecF 24 160.15 0.1463 0.0473 0.07 0.1463 0.0146 HGN 11 11.76 0.0339 0.0136 0.0182 0.0339 0.0034 HRM 27 69.26 0.0997 0.0305 0.0465 0.0997 0.01 NARM 40 158.06 0.1347 0.0431 0.0642 0.1347 0.0135 NPE 42 28.43 0.0626 0.0136 0.0247 0.0626 0.0063 RepeatNet 30 1792.03 0.193 0.0734 0.101 0.193 0.0193 SASRec 24 317.61 0.1251 0.0389 0.0588 0.1251 0.0125 SASRecF 35 496.47 0.1283 0.0406 0.0606 0.1283 0.0128 SHAN 23 80.48 0.105 0.0356 0.0516 0.105 0.0105 STAMP 34 47.94 0.105 0.0452 0.0657 0.1347 0.0135 TransRec 17 14.73 0.07 0.0178 0.0297 0.07 0.007 これらのレコメンドは以下の4種類に大別できます。 General Recommendation Graph Recommendation Knowledge Aware Recommendation Sequential Recommendation 特に、NGCFやLightGCNといったGraph Recommendationは、ItemKNNやBPRなどの旧来手法を超える精度を出しています。 また、ZOZOTOWNではレコメンドを活用するシーンが多く存在します。類似アイテムの推薦にはKnowledge Aware Recommendation。ユーザの短期のクリック予測にはSequential Recommendation。クーポン配信にはGraph Recommendation。各機能との相性を考慮し、最適なレコメンドアルゴリズムを選択する必要があります。 次の章では、これら4種類のレコメンダーシステムの特徴を紹介します。 General Recommendationに分類されるアルゴリズム GMF(Generalized Matrix Factorization) GMFはユーザとアイテムのembeddingをelement-wiseにかけたものから、マトリックスの中身を推定する線形モデルです。通常のMatrix Factorizationと同じモデルです。 NCF(Neural Collaborative Filtering) NCF はユーザとアイテムのembeddingを結合したものから多層パーセプトロンを用いてマトリックスの中身を推定する非線形モデルです。Matrix Factorizationの内積表現の部分をニューラルネットワークベースの関数に置き換えて学習します。 (引用: 論文リンク ) NeuMF(Neural Matrix Factorization) NeuMF ではGMFとNCFの出力を結合してマトリックスの中身の推定します。線形モデルと非線形モデルの組み合わせです。 (引用: 論文リンク ) Graph Recommendationに分類されるアルゴリズム グラフ構造は、ノード(頂点)とエッジ(辺)で構成されるデータ型を表します。GNN(Graph Neural Networks)は、グラフ構造を加味しながら各ノードをembeddingします。そして、レコメンド領域でのグラフ構造では、ユーザノードとアイテムノードからなる2部グラフを考えます。2部グラフとは、頂点集合を2つに分割して各部分の頂点は互いに隣接しないようにできるグラフのことです。 グラフベースレコメンドでは、GCN(Graph Convolutional Network)によってノードのembeddingを行います。CNNでは画像データは上下左右斜めの8方向から情報の畳み込み処理を行っているのに対し、GCNでは対象ノードの周辺あるいはグラフ全体の情報から畳み込みを行います。 (引用: 繋がりを可視化するグラフ理論入門 、 グラフ理論 (2) 、 介入効果推定の方法 ) NGCF(Neural Graph Collaborative Filtering) NGCF では、ユーザとアイテムのembeddingにグラフ畳み込み処理を行うことで、明示的にユーザとアイテムの交互作用を考慮します。 (引用: 論文リンク ) LightGCN(Light Graph Convolutional Network) LightGCN では、先のNGCF加えてグラフ上の特徴を平準化することによって計算量を抑えます。これによりGCNの最も重要なコンポーネントである近隣集約のみを考慮しています。 (引用: 論文リンク ) DGCF(Disentangled Graph Collaborative Filtering) DGCF では、ユーザとアイテムの間にインテント層を用意します。これによりユーザがアイテムを購入した潜在的な意図を表現し、購買理由を説明可能にします。 (引用: 論文リンク ) Knowledge Aware Recommendationに分類されるアルゴリズム ナレッジグラフは、柔軟かつ双方向的に事実「エンティティ」を格納する脳のような構造化データベースで、エンティティの相互にリンクされた意味付けを自由に表現します。レコメンド領域のナレッジグラフでは、エンティティとしてユーザとアイテムの他に、アイテムのジャンルやユーザの年齢層/性別などの各特徴量も表現可能です。 さらにナレッジグラフは意味ネットワークとして構築されるため、エンティティ間のセマンティック類似性(意味の類似性)を計算し、各アイテムの類似商品を推薦します。また、ナレッジグラフでは推薦理由を可視化し、データのスパース性問題を解決します。 (引用: A Survey on Knowledge Graph-Based Recommender Systems ) RippleNet RippleNet は、ナレッジグラフのリンクに沿ってユーザの潜在的な関心を自動的かつ反復的に拡張することにより、知識エンティティのデータセットに対するユーザの好みをembeddingします。ユーザによってアクティブ化された複数の波紋に従って、過去にクリックされたアイテムは候補アイテムに関するユーザの嗜好分布を形成し、最終的なクリック確率を予測します。 (引用: 論文リンク ) CKE(Collaborative Knowledge Base Embedding for Recommender Systems) CKE では、以下の3つを用いてアイテムのembeddingを取得します。 ナレッジグラフを用いたembedding(structural knowledge) 画像解析によって得られたembedding(visual knowledge) テキスト解析によって得られたembedding(textual knowledge) (引用: 論文リンク ) Sequential Recommendationに分類されるアルゴリズム 通常のGeneral Recommendationでは、ユーザがアイテムを消費する順序は考慮せず、長期予測としてユーザが最終的に消費するアイテムを予測します。シーケンシャルレコメンドの場合は、ユーザがアイテムを消費した順序に基づいて推薦を行い、ユーザが次に買いそうなアイテムを予測します。 シーケンシャルレコメンドの領域は、自然言語処理の分野と強い相関があります。自然言語処理では、単語の並ぶ方向からID化した単語をembeddingします。シーケンシャルレコメンドでも、ユーザの商品への消費履歴からアイテム情報をembeddingしていきます。ほとんどのシーケンシャルモデルは、自然言語モデルを元に作られています。 RNNRecommender RNNRecommender は、ユーザの1Session分の短期の行動(クリック履歴)から時系列データを取得し、RNNによってユーザのembeddingを行う手法です。RNNの層にGRUやLSTMを使用したり、双方向モデルを使用したりと、様々なアーキテクチャが存在します。 (引用: Sequential Recommendation with User Memory Networks ) Item2Vec Item2Vec は、自然言語処理で文脈から単語のembedding処理を行うWord2Vecを応用し、ユーザの行動履歴からアイテムのembeddingを行います。 (引用: Item2Vec-based Approach to a Recommender System ) BERT4Rec BERT4Rec の「BERT」はBidirectional Encoder Representations from Transformersの略です。2018年10月にGoogleのJacob Devlinらの論文で発表された自然言語処理モデルです。「AIが人間を超えた」と言わしめるほどのブレークスルーをもたらし、多様なタスクにおいて当時の最高スコアを叩き出しています。このBERTモデルをレコメンドに応用したものがBERT4Recです。 (引用: 論文リンク ) なお、自然言語処理の領域では、BERTの他にもALBERTやXLNetやGPT-xなど様々なモデルが登場しているため、それらを生かしたレコメンドモデルも試していきたいです。 深層学習×推薦のまとめ ここまで、ディープニューラルネットワーク時代のレコメンド技術の動向について4種類にわたり紹介してきました。一口にディープラーニングといっても、様々な分野の技術がレコメンドに生かされていることをご理解いただけたでしょう。ここで紹介した内容以外にも、強化学習を用いたレコメンド、深層学習を使わないレコメンドなどもあります。各分野のレコメンド技術に加え、特徴量エンジニアリング、ハイパーパラメータチューニングなど、さらなる精度向上を目指していきたいです。 短いライフサイクルで推薦を可能にするための計算時間を短縮する工夫 コラムとして本章では、推薦における計算時間を短縮するためのテクニックを紹介します。ZOZOTOWNでは、毎日100万以上のアクティブユーザと、100万のアイテム数を取り扱っています。ファッションアイテムは商品のライフサイクルが短いため、推薦結果を出すまでの計算時間は重要です。 スパースマトリックスによるデータ量削減 数百万のアクティブユーザと、数百万のアイテムのマトリックスデータを扱おうとすると、数TBのメモリが必要になります。そのようなサーバーを用意するのも大変ですが、用意できたとしてもインフラコストが格段に高いため、レコメンドによる売り上げ増加を食い潰してしまいます。 そのため、データ量を削減する工夫として、スパースマトリックス(疎行列)をインプットとして用います。レコメンドのインプット成分のほとんどがゼロであるため、疎行列の非零要素だけを工夫してうまく格納することにより大次元の問題を扱うことが容易になり、比較的少ない手間でベクトルと行列の積を計算できます。 ZOZOTOWNの場合、スパースマトリックスを用いることでインプットのデータ量を約10万分の1に削減できます。 Cython Pythonはインタプリタ言語であるため、処理速度は遅い部類に属します。そこで、コンパイラ言語であるC/C++に変換することにより高速化しようというのがCythonです。Factorization Machineモデルでは、Cythonベースのライブラリを用いることで、高速化を実現しています。ZOZOTOWNのリアルデータでも10分強でモデルの作成を終えることができます。 GPU embeddingの計算は、TensorFlowやPyTorchを用いてテンソルの処理をGPUで行います。NumPyに比べてテンソル計算は100倍以上、GPUを用いるとさらに3倍早くなる印象です。 最後に ZOZOテクノロジーズでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
はじめに こんにちは。EC基盤本部SRE部プラットフォームSREの 三神 です。 2021年3月18日、ZOZOTOWNは大規模なリニューアルをしました。その中でも、コスメ専門モールの ZOZOCOSME と、ラグジュアリー&デザイナーズゾーンの ZOZOVILLA を同時にオープンし、多くの反響をいただきました。 今回のリニューアルではBackends For Frontends(以下、BFF)にあたるZOZO Aggregation APIを構築しています。本記事ではZOZOTOWNが抱えていた課題とBFFアーキテクチャを採用した理由、またZOZO Aggregation API構築時に発生した課題と解決法についてご紹介します。 ZOZO Aggregation APIのサービスメッシュについてはこちらの記事でご紹介していますので合わせてご覧ください。 techblog.zozo.com BFFとは BFFはアーキテクチャ設計パターンの1つです。フロントエンドのリクエストに応じて各種のAPIコールをしたり、バックエンドから取得した内容を加工してフロントエンドに返却したりするフロントエンド専用のサーバーを用意するアーキテクチャ設計パターンです。 より詳細な役割は こちら を御覧ください。 ZOZOTOWNにおけるBFFの役割 今回のZOZOTOWNリニューアルにおける大きな特徴の1つに「パーソナライズ」が挙げられます。 我々は、ZOZOTOWNにおいてユーザーと商品のより良い出会いを提供したいと考えています。そのためには画面に表示する情報も画一的なものではなく、ユーザーの趣味嗜好に合わせた商品情報をお届けする必要があります。 これを実現するために、リニューアルしたZOZOTOWNではユーザーが登録している情報から適切な商品情報を提供する機能を実装する事にしました。さらに、商品情報を提供する際にはユーザーが利用しているクライアントに合わせて表示方法を変更する事でより良い体験を提供する事も重視しました。 これらの要件を満たすために、BFFであるZOZO Aggregation APIが各種処理を実施しています。ZOZO Aggregation APIでは各バックエンドから取得した情報をモジュールという単位で管理します。モジュールは性別や年齢、お気に入りブランドといった情報によって取得する内容が異なっており、複数のモジュールからパーソナライズする処理をZOZO Aggregation APIにて行っています。接続したクライアント毎にモジュールの数も調整しており、各クライアントのUIに合わせて最適な形でレスポンスを返す処理もZOZO Aggregation APIが担当しています。 なぜBFFを採用したのか? 下記の記事でも紹介している通り、昨年よりZOZOTOWNのリプレイスの一環でシステムのマイクロサービス化を進めています。 認証機能 や検索機能といった様々なシステムのマイクロサービス化を進めており、今後もその範囲は拡大していく予定です。 techblog.zozo.com リニューアル後のZOZOTOWNではパーソナライズ機能を強化しているので、クライアントが必要とするデータの種類が増えました。そして、マイクロサービス化が進む事で、クライアントが必要なデータを取得するために多数のマイクロサービスへリクエストする必要性が出てきます。この様な背景から下記の課題が出てきました。 クライアントが接続するマイクロサービスの増加により、各マイクロサービスのAPI仕様とクライアント実装が複雑になる 各APIにアクセスを行うため、クライアント・サーバー間の通信量が増加する リプレイスの進捗に応じてバックエンドAPIの粒度や提供データの内容に修正が発生した場合、各クライアントが要件に追従する必要がある 上記課題の解決手段としてBFFアーキテクチャを採用する事にしました。 BFFの存在により、下図のようにフロントエンドはBFFにのみリクエストを送る事になり、通信量の肥大化を防ぐ事ができます。クライアントの実装も接続先はBFFのみとなるのでシンプルにできます。また、バックエンドAPIに修正があった際もBFFにてその対応が吸収できるので、各クライアントでの対応は不要です。 これら踏まえ、各バックエンドの情報を集約/整形してフロントエンドに返す処理をするBFFをZOZO Aggregation APIとして実装する事にしました。 ZOZOTOWNにおけるBFFアーキテクチャ ZOZO Aggregation APIはZOZO API Gateway(以下、API Gateway)の配下に設置する設計で構築しています。 ZOZOTOWNではAPI Gatewayパターンのアーキテクチャを採用しており、認証認可やカナリアリリース機能を備える高機能な内製API Gatewayを軸にしたシステム構成となっています。BFFであるZOZO Aggregation APIはAPI Gateway配下の1マイクロサービスとして設置しており、リクエストはAPI Gatewayを経由してルーティングされます。 なお、API Gatewayに関しては以下の記事でご紹介していますので合わせてご覧ください。 techblog.zozo.com 次に、ZOZO Aggregation APIをリリースする上で発生した課題を2点ご紹介します。 BFFによるキャッシュの一元化 リニューアル後のシステム要件を整理する中でZOZO Aggregation APIにおけるバックエンドの最大負荷が想定以上に多い事が判明しました。 リニューアル後のトップページでは、パーソナライズを強化するためにバックエンドAPIへのリクエストがリニューアル前に比べて増加していました。それに加え、セール等のイベント時は通常時と比較して圧倒的に負荷が高くなるため、スパイクを考慮する必要もあります。 リニューアル後のセール時に発生するスパイクをシミュレーションすると、既存の各バックエンドの規模では負荷に耐えられない事がわかりました。バックエンドが高負荷状態になり、ZOZO Aggregation APIのレスポンスが遅延した場合、トップページ生成時間が長くなるのでユーザー体験を著しく損なう可能性があります。 この問題の対策として、各バックエンドの増設もしくはキャッシュの導入を検討しました。前者の増設による対策の場合、すべてのバックエンドをイベント毎に増設する必要があり、そのための工数や維持費用が膨れ上がり現実的な解決策とは言えませんでした。そこで、後者のキャッシュによる解決策を中心に検討を進めていきました。 トップページにおけるアクセス増が原因なので、AkamaiやFastlyといったCDNにてキャッシュする事による負荷軽減を模索しました。しかし、パーソナライズを実現するにあたり、多種多様なモジュール内容と組み合わせを想定しているため、ユーザーごとに表示される内容に差異が多い仕様でした。したがって、ZOZO Aggregation APIにて集約した後のページをキャッシュするCDNの様な方式は、本サービスにおける負荷対策としてあまり効果的ではないと判断しました。 そこで、モジュール単位でキャッシュする案を検討しました。ページを生成する前のモジュール単位であれば、同一条件下でのレスポンスデータ生成においてキャッシュが利用できます。そのため、モジュール単位でキャッシュするシステム構成に変更しました。ZOZO Aggregation APIと各バックエンドの間に ElastiCacheによるRedis を利用する事で各モジュールをキャッシュする仕組みを構築しました。 BFFの存在のおかげで、キャッシュ利用に関する実装はBFF内で完結させる事ができました。BFFが無ければ各フロントエンドでキャッシュに関する実装が必要となり、開発工数が大きく増加していました。 下図はキャッシュが無いタイミングのアプリケーショントレーシングの結果です。ZOZO Aggregation APIが各バックエンドに対して大量にリクエストしている事がわかります。 そして、次に示す図はキャッシュがある場合のアプリケーショントレーシングの結果です。ZOZO Aggregation APIが各モジュールのキャッシュを取得しているため、バックエンドに対する負荷が下がっている事がわかります。また、レイテンシも200msから40msと短縮されており、レスポンス速度も大幅に改善している事がわかります。 キャッシュを非常に活用できている状態になったのですが、運用上の課題が一点残りました。 ZOZOTOWNでは毎日お得なクーポンを発行しており、利用できるクーポンが毎日午前0時に切り替わります。ZOZO Aggregation APIではクーポン情報も保有しているので、午前0時のタイミングで強制的にバックエンドから最新情報を取得する仕様となり、キャッシュスタンピード状態になる可能性がありました。この課題に関しても、記事を執筆予定なので、是非ご期待ください。 BFFにおけるサービス可用性の考慮 BFFはフロントエンドからリクエストを受けるため、BFFに障害が発生した場合はサービス障害に直結しやすい傾向にあります。ZOZO Aggregation APIもトップページを生成するために利用されるので、障害時はZOZOTOWNのサービス自体に影響します。ところが、BFFは複数のバックエンドと通信するアーキテクチャである事から、いずれかのバックエンドにて障害が発生した際に、その影響を受けてしまう懸念がありました。サービスとしての可用性を担保するにはこの課題の対策が必要です。 そこで、ZOZO Aggregation APIでは、特定のモジュールが取得できない場合は取得済みモジュールのみでレスポンスを行う仕様にしました。タイムアウトとリトライ制御をバックエンド毎に設定しておき、バックエンドが期間内にレスポンスを返さない場合は、その他のバックエンドから取得できたモジュールのみでレスポンスを返します。 ZOZO Aggregation APIと各バックエンド間の通信におけるタイムアウトとリトライ制御は、Istioのトラフィック制御機能で実現しています。ZOZOTOWNマイクロサービスプラットフォームにおけるIstioの活用については こちら の記事で紹介しているので、是非ご覧ください。 apiVersion : networking.istio.io/v1alpha3 kind : VirtualService metadata : name : zozo-test-api-vs spec : hosts : - zozo-test-api-vs.zozo-test.svc.cluster.local http : - route : - destination : host : zozo-test-api-vs.zozo-test.svc.cluster.local subset : zozo-test-api weight : 100 retries : attempts : 1 perTryTimeout : 8s retryOn : 5xx,connect-failure timeout : 9s 下図で示す通り、アプリケーショントレーシングでも特定のモジュールがタイムアウトした場合、ZOZO Aggregation API自身は取得できたモジュールのみで返却している事がわかります。 なお、現在はタイムアウトとリトライ制御のみですが、バックエンドとの通信のさらなる回復性向上のために サーキットブレーカー の採用を検討しています。 まとめ 今回はマイクロサービス化が進むZOZOTOWNにおけるBFFの有効性と構築時に発生した課題2点を紹介しました。BFFを採用した事でAPI実装やクライアント実装がシンプルになり、効率的なキャッシュ実装や通信量削減などの様々なメリットを実感しています。 今後もBFFを活用して様々な機能を追加し、適切なマイクロサービス環境の運用を目指していきます。新たな知見が得られた際はまたご紹介します。 最後に ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、下記のリンクからぜひご応募ください。 tech.zozo.com
こんにちは。アーキテクト部の廣瀬です。 弊社ではサービスの一部にSQL Serverを使用しており、BigQuery上のデータ基盤へテーブルを連携しています。連携の仕組みは非常によくできているものの、データ不整合や遅延が発生し得るという課題を抱えていました。しかし、SQL Serverのスナップショット分離レベルを導入することでそれらを解決できました。本記事では、抱えていた課題および解決までの流れと、スナップショット分離レベルを導入する際に気を付ける点を紹介します。 データ基盤連携の方法と課題 データ基盤との連携方法は、日次連携とリアルタイム連携の2種類です。それぞれの連携方法と抱えていた課題について説明します。 日次連携 1日1回、SQL Server専用の一括コピーツールである「bcp」を使用してテーブル全体のデータを取得する連携方法です。データ取得時のSQLのイメージは以下の通りです。 SELECT #{columns} FROM #{@tablename} WITH (NOLOCK) この方法では、テーブルサイズの大きさに応じてデータ取得にかかる時間も長くなります。SQL Serverにおけるデフォルトのトランザクション分離レベルは「READ COMMITTED」です。そのため、ユーザー操作によって発行される更新クエリをブロックしてしまう懸念があり、それを避けるために「WITH(NOLOCK)」を付与しています。 この「WITH(NOLOCK)」ヒントをつけるとトランザクション分離レベルが「READ UNCOMMITTED」になります。この分離レベルではダーティリードを許可するため、データの読み取り中にページ分割が起こると、データの欠損や重複などの不整合につながります。データ基盤はアプリのPUSH配信にも使われているため、重複を避けるための工夫を配信側で実装する手間や、データ欠損による機会損失が発生していました。なお、「WITH(NOLOCK)」ヒントとページ分割の関係性については こちらの記事 で詳しく解説されています。 このように「READ COMMITTED」でも「READ UNCOMMITTED」でも、それぞれに懸念がありました。しかし、どちらかを受け入れるしかないため、ユーザー操作への悪影響を避けることを優先して「READ UNCOMMITTED」分離レベルを採用していました。 リアルタイム連携 約1分に1回、弊社で開発したリアルタイムデータ連携の仕組みを使い、直近で更新のあった差分データのみを取得する連携方法です。なお、リアルタイムデータ連携基盤に関する詳しい内容については、下記の記事をご参照ください。 techblog.zozo.com 上記記事で紹介しているデータ取得時のSQLのイメージは以下の通りです。 SELECT a.SYS_CHANGE_OPERATION AS changetrack_type, a.SYS_CHANGE_VERSION AS changetrack_ver, #{columns} FROM CHANGETABLE(CHANGES #{@tablename}, @前回更新したバージョン) AS a LEFT OUTER JOIN #{@tablename} ON a.#{@primary_key} = b.#{@primary_key} この方法では、差分データのみを取得するため、データの取得が高速に完了します。そのため、データ取得クエリが他のクエリを長時間ブロックする懸念はほぼありません。したがって、「WITH(NOLOCK)」ヒントをつけずに「READ COMMITTED」分離レベルでクエリ実行しています。 しかし、該当テーブルへ長時間の更新クエリが実行されている状況だと、逆にデータ取得クエリがブロックされてデータの同期遅延が発生することがありました。ブロックされて待ち続けた場合にロックの状況が悪化しないよう、クエリ実行時にロックのタイムアウト設定を入れたり、インターバルを60秒と長めにとるという工夫もしています。 連携方法と課題のまとめ ここまでの説明をまとめると、以下の通りです。 連携の方法は日次とリアルタイムの2種類が存在 日次連携では「WITH(NOLOCK)」付きで「READ UNCOMMITTED」分離レベルでクエリを実行 課題:ダーティリードを許可しているため、データの欠損や重複などの不整合が起こり得る リアルタイムデータ連携では「READ COMMITTED」分離レベルでクエリを実行 課題:他のクエリが更新クエリを長時間実行中だと、ブロッキングによりデータの同期遅延が起こり得る 以降では、これらの課題をどのように解決したか、順に説明します。 トランザクション分離レベルの検討 今回の課題を解決するには、「READ UNCOMMITTED」分離レベルを使用せずに、他の更新処理によって連携クエリがブロックされない状況を作る必要があります。そのためにはトランザクション分離レベルを変更する必要があります。 まず、SQL Serverのトランザクション分離レベルについて簡単に説明します。 SQL Serverには、5つのトランザクション分離レベルが用意されています。 READ UNCOMMITTED READ COMMITTED REPEATABLE READ SNAPSHOT SERIALIZABLE デフォルトの分離レベルは「READ COMMITTED」であり、これは変更できません。 トランザクション単位での分離レベルは個別に指定可能で、未指定時はデフォルトの分離レベルとなります。 分離レベルの指定は以下のクエリで実行可能です。 SET TRANSACTION ISOLATION LEVEL <分離レベル名> また、分離レベルではないものの、「READ COMMITTED」の挙動を変化させるデータベースオプション「READ_COMMITTED_SNAPSHOT」(READ COMMITTED SNAPSHOT ISOLATION : RCSI) も存在します。 このオプションをONにすると、データ更新時にコミット済みのレコード(トランザクション内で変更する前の状態のデータ)がtempdbへと書き込まれるようになります。そしてSELECTクエリを実行した際は、必要に応じてtempdbに格納されたコミット済みデータを読み取ることで、ロック無しで整合性のとれたデータを取得できます。 このオプションのON/OFFも考慮すると、分離レベルは以下の6つに分類できます。 READ UNCOMMITTED READ COMMITTED(READ_COMMITTED_SNAPSHOT OFF) READ COMMITTED(READ_COMMITTED_SNAPSHOT ON) REPEATABLE READ SNAPSHOT SERIALIZABLE この中で「READ UNCOMMITTED」分離レベルを使用せずに、他の更新処理によってSELECTクエリがブロックされない状況を作るには、 READ COMMITTED(READ_COMMITTED_SNAPSHOT ON) SNAPSHOT のどちらかの分離レベル(+オプション)を設定する必要があります。 そのため、この2種類の選択肢について比較検討を実施しました。 READ COMMITTED(READ_COMMITTED_SNAPSHOT ON) vs. SNAPSHOT どの時点のデータを読み取るか READ COMMITTED(READ_COMMITTED_SNAPSHOT ON) 各ステートメント(SELECT文)を発行したタイミングで、コミットされていたデータ SNAPSHOT トランザクションを開始したタイミングでコミットされていたデータ 同一リソースへの書き込みが競合した場合の挙動 READ COMMITTED(READ_COMMITTED_SNAPSHOT ON) ブロッキングが発生 SNAPSHOT トランザクションの開始後、他のクエリによって変更されたデータに対して変更をコミットしようとすると、ロールバックされエラーとなる(詳細は ドキュメント を参照) 読み取りの挙動が変化する範囲 READ COMMITTED(READ_COMMITTED_SNAPSHOT ON) オプションをONにした時点ですべてのセッションが影響を受け、コミット済みデータだけをロック無しで読み取るようになる 既存アプリケーションへの影響がある(読み取り処理とデータ更新処理との競合がなくなる) SNAPSHOT SNAPSHOT分離レベルを指定したセッションのみが影響を受ける 比較検討の結果 今回課題を抱えているのは読み取りのみのワークロードです。したがって、書き込みの競合を考慮する必要はありません。また、何年も運用されているDBのため「READ_COMMITTED_SNAPSHOT」オプションをONにすると既存のアプリケーションの挙動に予期せぬ変化が生じる懸念もありました。一方で、SNAPSHOT分離レベルの場合は明示的に分離レベルを指定したセッションのみが影響を受けるため、既存アプリケーションの挙動は一切変化しません。 以上の考察を踏まえ、最終的にSNAPSHOT分離レベルを導入することにしました。 SNAPSHOT分離レベルの導入 SNAPSHOT分離レベルに切り替えるためには、該当セッションで以下のクエリを実行します。 SET TRANSACTION ISOLATION LEVEL SNAPSHOT ただし、データベースオプション「ALLOW_SNAPSHOT_ISOLATION」が有効化されている必要があります。 ALTER DATABASE <データベース名> SET ALLOW_SNAPSHOT_ISOLATION ON このオプションを運用中の本番環境に適用する際には注意点があるので紹介します。 導入時の注意点 「ALLOW_SNAPSHOT_ISOLATION」の有効化はオンラインで実施可能です。 ただし、「ALTER DATABASEを実行する前に開始されたトランザクション」が存在する限り、ALTER文の実行は完了しません。「ENABLE_VERSIONING」という待ち事象で待ち続けることになります。 なお、「ALTER DATABASEを実行した後に新たに開始されたトランザクション」についてはALTER文の実行を妨げることはありません。 ドキュメント には以下の記載があります。 ALLOW_SNAPSHOT_ISOLATION を新しい状態に (ON から OFF へ、または OFF から ON へ) 設定した場合、ALTER DATABASE は、データベース内にあるすべての既存のトランザクションがコミットされるまで、呼び出し元に制御を返しません。 データベースが既に ALTER DATABASE ステートメントで指定した状態にある場合には、制御は呼び出し元に直ちに返されます。 実際に弊社の環境で導入した際は、瞬時に完了したDBもあれば、完了まで90秒程度かかったDBもありました。 基本的にこのALTER文が他のクエリをブロックすることは無い認識ですが、万一の事態に備え、ALTER文の実行中は常に sys.dm_exec_requests を使い、実行中のクエリでブロッキングが発生していないかを監視することをおすすめします。 導入後の注意点 導入後は、データの書き込みが発生する度にtempdbにコミット済みのレコード情報が書き込まれるようになるため、tempdbの負荷が上昇します。 この性質を念頭において、パフォーマンスモニタの以下のメトリクスで目立った変化が無いかを確認します。 CPUの高騰がみられないか Processor¥% Processor Time 同時実行性の低下はみられないか SQLServer:Statistics¥Batch Requests/sec SQLServer:General Statistics¥Processes blocked 「行のバージョン管理」関連メトリクスで気になる変化はないか SQL Server:Transactions¥Free Space in tempdb(KB) SQL Server:Transactions¥Version Store Size(KB) SQLServerTransactions¥Version Cleanup rate (KB/s) SQL Server:Transactions¥Version Generation rate (KB/s) tempdbのディスク負荷は問題ないか Physical Disk (tempdbのドライブ)¥Disk Read Bytes/Sec Physical Disk (tempdbのドライブ)¥Disk Write Bytes/Sec Physical Disk (tempdbのドライブ)¥Current Disk Queue Length 行のバージョン管理に使用するtempdbの領域は、定期的に自動でクリーンアップされます。領域サイズが増え続けずに、定期的に減少するタイミング(行のバージョン管理のクリーンアップ)があること必ず確認します。 あわせて、sys.dm_exec_requestsを使って、リアルタイムでクエリの同時実行性についても確認しておくとより安心です。基本的には、上記内容に気を付けつつ導入および導入後の評価を実施すれば、安心してSNAPSHOT分離レベルを使用できるかと思います。 監視項目 「ALLOW_SNAPSHOT_ISOLATION」を有効化した後は、以下の2点は必ず監視しましょう。 tempdbの容量逼迫の検知 環境によっては、大量のデータ更新などの理由でtempdbの空き容量の枯渇が懸念されます。90%を超えたらアラートを発報するなど、検知できる仕組みを用意しておきましょう。 長時間開きっぱなしのトランザクションの検知 行のバージョン管理のクリーンアップについて、 ドキュメント に以下の記載があります。 バージョンストアに格納されているバージョンは、行のバージョン管理に基づく分離レベルで実行されるトランザクションで必要な限り保持されます。 SQL Server データベース エンジンにより、必要なトランザクション シーケンス番号の中で最も小さい番号が追跡され、それよりもトランザクション シーケンス番号が小さい行のバージョンは定期的にすべて削除されます。 つまり、開きっぱなしのトランザクションがあると、そのトランザクションより後に開始されたトランザクションによってtempdbに書き込まれたデータはいつまでもクリーンアップされません。この状況になるとtempdbの容量逼迫につながる懸念があるため、例えば以下のようなクエリを定期的に実行してレコードが取得できた場合は通知する仕組みを用意しておきます。 -- 60分以上開きっぱなしのトランザクションを検知 SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED SELECT TOP ( 1 ) ' transaction_time_min: ' + isnull( cast (datediff(minute, transaction_begin_time, getdate()) AS VARCHAR ( max )), '' ) + ' / session_id: ' + isnull( cast (es.session_id AS VARCHAR ( max )), '' ) + ' / host_name: ' + isnull( cast (host_name AS VARCHAR ( max )), '' ) + ' / program_name: ' + isnull( cast (program_name AS VARCHAR ( max )), '' ) + ' / status: ' + isnull( cast (es.STATUS AS VARCHAR ( max )), '' ) + ' / last_request_end_time: ' + isnull( cast (last_request_end_time AS VARCHAR ( max )), '' ) + ' / text: ' + isnull( cast (TEXT AS VARCHAR ( max )), '' ) AS result FROM sys.dm_tran_session_transactions ts JOIN sys.dm_exec_sessions es ON es.session_id = ts.session_id JOIN sys.dm_tran_active_transactions at ON at.transaction_Id = ts.transaction_id LEFT JOIN sys.dm_exec_requests der ON es.session_id = der.session_id OUTER APPLY sys.dm_exec_sql_text(sql_handle) AS dest WHERE datediff(minute, transaction_begin_time, getdate()) > 60 ORDER BY datediff(minute, transaction_begin_time, getdate()) DESC 導入効果 「ALLOW_SNAPSHOT_ISOLATION」オプションを有効化し、データ基盤への連携クエリだけ「SNAPSHOT」分離レベルを使用することで、抱えていた以下の課題を解決できました。 日次連携:WITH(NOLOCK)つきのクエリを実行することによるデータ不整合 ブロッキングの懸念が無くなったため、WITH(NOLOCK)を外すことができた リアルタイム連携:「READ COMMITTED」分離レベルでクエリを実行する際に他のクエリにブロックされる ブロックされることが無くなったため、遅延が発生しなくなりデータ基盤への連携が安定した ブロックすることも無くなったため、同期のインターバルを短く設定してより早く連携できるようになった また、既存のアプリケーションの挙動については一切変化しないため、予期せぬ不具合が発生することも避けることができました。 導入後に起きた問題 ほとんどのDBは上記内容でスムーズに導入できましたが、一部のDBでは導入後に問題が発生して切り戻しました。発生した問題点と、策定した解決方法を紹介します。 トランザクションログのバックアップサイズが急激に肥大 「ALLOW_SNAPSHOT_ISOLATION」オプションを有効化したあとに特定のDBだけ、トランザクションログファイルのバックサイズが約100倍に肥大しました。tempdbへの書き込みが増加することは認識していましたが、ユーザーDBのトランザクションログファイルがここまで急激に肥大することは考慮できていませんでした。 ログ肥大の原因調査 「ALLOW_SNAPSHOT_ISOLATION」オプションを有効化する前後のバックアップログファイルをテーブルにダンプして解析しました。 まず、以下のクエリでログファイルをテーブルにINSERTします。 SELECT * INTO tran_log_dump FROM sys.fn_dump_dblog( NULL , NULL , NULL , 1 , N ' C:\***\backup_log_file.trn ' , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL , NULL ) 次に、INSERTしたテーブルをOperation(INSERT/DELETEなど)、Context(HEAP/CLUSTEREDなど)、AllocUnitId(テーブル)単位で集計し、合計のトランザクションログサイズが大きい順に表示しました。 SELECT * , SUM (cnt) OVER () AS sum_cnt , SUM (sum_log_record_length) OVER () AS sum_all_log_record_length , SUM (sum_log_reserve) OVER () AS sum_all_log_reserve FROM ( SELECT Operation ,Context ,AllocUnitId , COUNT (*) AS cnt , SUM ( CAST ([ Log Record Length ] AS BIGINT)) AS sum_log_record_length --単位:byte , SUM ( CAST ([ Log Reserve] AS BIGINT)) AS sum_log_reserve --単位:byte FROM tran_log_dump WITH (NOLOCK) GROUP BY Operation ,Context ,AllocUnitId ) AS A ORDER BY sum_log_record_length DESC 上の図は、「ALLOW_SNAPSHOT_ISOLATION」オプションを有効化した後のトランザクションログです。図のように、1位だけログサイズも、ログの件数も突出していました。有効化前のトランザクションログと比較すると、LOP_MODIFY_ROW(行のUPDATE)の出現回数が約2500倍になっていました。 どのテーブルへのUPDATEが大量に行われているのか確認するために、AllocUnitIdを使ってテーブル名を解決しました。 SELECT allocation_unit_id, object_name(object_id) FROM sys.allocation_units WITH (NOLOCK) JOIN sys.partitions WITH (NOLOCK) ON container_id = hobt_id WHERE allocation_unit_id IN ( 12 * * * * 34 ) その後、該当のテーブルのUPDATE回数をdm_db_index_operational_statsを使って確認しました。 SELECT * FROM sys.dm_db_index_operational_stats(db_id( ' DatabaseName ' ), NULL , NULL , NULL ) WHERE object_id = object_id( ' TableName ' ) AND database_id = db_id( ' DatabaseName ' ) 図のように、UPDATE回数が約6兆回と、INSERTやDELETEといった他の操作と比べても突出して大きな値となっていました。このテーブルへUPDATEしている処理はどういったものがあるのかキャッシュから確認したところ、5分に1回のペースで定期的に該当テーブルを更新しているバッチ処理がありました。この処理のクエリは、該当テーブルのほぼ全レコードに対してUPDATEを実行していましたが、大半のレコードは同じ値でUPDATEされていることも分かりました。そのため、同じ値でカラムをUPDATEしたときの挙動について、「ALLOW_SNAPSHOT_ISOLATION」オプションの有効化前後で比較を実施しました。 「ALLOW_SNAPSHOT_ISOLATION」オプションの有効化前後でのトランザクションログの比較 以下の検証用のクエリは、同一のテーブルを同じレコード数、同じ値でUPDATEした際のトランザクションログの中身を確認するクエリです。「ALLOW_SNAPSHOT_ISOLATION」オプションの無効化時と有効化時の結果をそれぞれ確認できます。 SET NOCOUNT ON GO DROP TABLE IF EXISTS UpdateTest GO CREATE TABLE UpdateTest ( C1 INT PRIMARY KEY CLUSTERED ,C2 INT ,C3 INT ,C4 INT ,C5 INT ) GO -- 10000レコード、ランダムな値でINSERT DECLARE @cnt INT = 1 BEGIN TRAN WHILE (@cnt <= 10000 ) BEGIN INSERT INTO UpdateTest VALUES (@cnt, RAND() * 100 , RAND() * 100 , RAND() * 100 , RAND() * 100 ) SET @cnt += 1 END COMMIT TRAN; GO -- ログバックアップによりトランザクションログを切り捨てる CHECKPOINT BACKUP DATABASE TEST TO DISK = N ' NUL ' CHECKPOINT BACKUP LOG TEST TO DISK = N ' NUL ' GO -- トランザクションログの中身をチェック(この時点では空っぽのはず) SELECT * FROM sys.fn_dblog( NULL , NULL ) WHERE AllocUnitName LIKE ' %UpdateTest% ' GO -- 「ALLOW_SNAPSHOT_ISOLATION」オプション無効化 ALTER DATABASE TEST SET ALLOW_SNAPSHOT_ISOLATION OFF GO -- C1=1のカラムの存在チェック SELECT * FROM UpdateTest WHERE C1 = 1 GO -- 同じ値でカラムをUPDATE UPDATE UpdateTest SET C2 = C2 WHERE C1 = 1 GO 10 -- トランザクションログの中身をチェック(ここの結果を比較したい) SELECT * FROM sys.fn_dblog( NULL , NULL ) WHERE AllocUnitName LIKE ' %UpdateTest% ' -- 「ALLOW_SNAPSHOT_ISOLATION」オプション有効化 ALTER DATABASE TEST SET ALLOW_SNAPSHOT_ISOLATION ON GO -- 同じ値でカラムをUPDATE UPDATE UpdateTest SET C2 = C2 WHERE C1 = 1 GO 10 -- トランザクションログの中身をチェック(ここの結果を比較したい) SELECT * FROM sys.fn_dblog( NULL , NULL ) WHERE AllocUnitName LIKE ' %UpdateTest% ' 上記クエリの実行結果は以下のようになりました。「ALLOW_SNAPSHOT_ISOLATION」オプションの無効化時(上段)は、同じ値でUPDATEした場合はトランザクションログに書き込まれていません。一方で、有効化時(下段)は同じ値でUPDATEしてもトランザクションログに書き込みが行われるようになっていました。 「ALLOW_SNAPSHOT_ISOLATION」オプションを有効化すると、各レコードにバージョン情報のタイムスタンプを保持するようになります。今回の実験における挙動の違いは、有効化時はタイムスタンプだけが更新され、その更新情報がトランザクションログに書き込まれたものと推測されます。 次に、その推測通りの挙動になっているかを確認しました。「ALLOW_SNAPSHOT_ISOLATION」オプションを有効化した状態で、同じ値でUPDATEする前後のデータページの中身を確認します。 DBCC TRACEON( 3604 ) DBCC PAGE (N ' TEST ' , 1 , 2875640 , 3 ) WITH TABLERESULTS DBCC TRACEOFF( 3604 ) GO UPDATE UpdateTest SET C2 = C2 WHERE C1= 1 GO 10 DBCC TRACEON( 3604 ) DBCC PAGE (N ' TEST ' , 1 , 2875640 , 3 ) WITH TABLERESULTS DBCC TRACEOFF( 3604 ) 推測通り、UPDATEの前後で行のバージョン情報である、「Transaction Timestamp」が更新されていることが確認できました。 したがって、ログ肥大の原因は「トランザクションログへの書き込みの挙動がオプション有効化によって変化し、同じ値で大量のレコードを更新している処理がログへ書き込まれるようになったため」と判断できます。 対応策 問題となったバッチ処理では、ほとんどのレコードは同じ値でUPDATEされているため、変化があったレコードだけを更新する差分更新に処理を修正することでログ肥大を抑えられると考えられます。リリースに向けて現在対応中です。 まとめ 本記事では、SQL Serverからデータ基盤へとデータを連携する際に抱えていた課題について説明し、スナップショット分離レベルを導入することで課題を解決するまでの流れ(分離レベルの選定、導入前後の注意点、導入後の問題点)を紹介しました。同じような課題を抱えている方の参考になれば幸いです。 最後に ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
こんにちは、DATA-SREチームの塩崎です。最近気になるニュースは「ネコがマタタビを好む理由が蚊を避けるためだった 1 」です。 さて、皆さんはデータ基盤で集計した結果をどのようにして確認していますか。LookerやPower BIなどのBIツールを使って綺麗なダッシュボードを作成している方も多いかと思います。しかし、全員が毎日確認すべき数値はSlackなどの全員が日常的に目にする場所へ掲げたいです。本記事ではBigQueryとSlackを連携させる機能をノーコードで作成する方法を紹介します。 従来手法 BigQueryで集計した結果をSlackに通知するためにはGoogle Apps Script(以下、GAS)を用いるやり方が現在では主流です。GASの文法はJavaScriptとほぼ同じであり、普段分析をメインで担当している人たちには馴染みの薄い言語です。また、Cloud FunctionsとCloud Schedulerを組み合わせて定期的に集計結果をSlackへ通知できますが、これも同様に分析メインな人たちにとっては難易度が高いです。 そのため、Slack通知するためのBotの作成と運用をエンジニアに依頼するという業務フローを採っている組織もあるかと思います。この工数が非常に大きいわけではありませんが、可能ならばエンジニアリソースを使わずにSlackへの通知を実現させたいです。 提案手法 今回提案する手法の全体図を以下に示します。 BigQuery→Google Sheetsの連携にはConnected Sheetsを使い、Google Sheets→Slackへの連携にはSlack Workflow Builderを使います。Google Sheetsを仲介させることで、SQLのみで集計結果をSlackに通知することが実現できます。 Connected Sheets Connected SheetsはBigQueryとGoogle Sheetsを繋ぐ機能です。BigQueryに対してクエリを実行した結果をGoogle Sheetsに挿入したり、Google Sheetsにおけるピボットテーブルを自動的にSQLに変換したりできます。今回はクエリの実行結果をGoogle Sheetsへ挿入するために使用しています。 cloud.google.com support.google.com Slack Workflow Builder Slack Workflow Builderは定型的なプロセスをワークフロー化して、Slackで実行するための機能です。デフォルトの状態では、メッセージの送信やフォームの表示などしかできませんが、サードパーティ製のアプリを導入すると外部サービスと連携できます。 slack.com 今回は以下のアプリを使ってGoogle Sheetsとの連携をします。 slack.com 手順 それでは、実際にやってみましょう。今回はお題として「毎朝10時にBigQueryのログを確認し、昨日の利用費が多い人Top3を通知する」を実現させます。 BigQueryでのジョブの実行履歴は、 INFORMATION_SCHEMA の JOBS_BY_ORGANIZATION から取得します。 cloud.google.com 完成したクエリを以下に示します。第1列に行番号を入れているのは、Google SheetsとSlackを連携させる時に必要なためです。 select row_number() over ( order by sum (total_bytes_billed) desc ) as row_num, user_email, cast ( sum (total_bytes_billed) / 1024 / 1024 / 1024 / 1024 * 5 as int64) as total_cost_in_usd from `region-us`.INFORMATION_SCHEMA.JOBS_BY_ORGANIZATION where date (creation_time, ' Asia/Tokyo ' ) = current_date ( ' Asia/Tokyo ' ) - 1 and reservation_id is null group by user_email order by total_cost_in_usd desc BigQueryとGoogle Sheetsの連携 まずはBigQueryとGoogle Sheetsを連携させます。 メニューバーから「Data」→「Data connectors」→「Connect to BigQuery」を選択します。 課金プロジェクトの選択画面が表示されるので、適当なプロジェクトを選択したあとに「Write Custom Query」を選択してクエリエディタを開きます。ここに先程のクエリを入力して、「Connect」を選択します。 すると、クエリを実行した結果がGoogle Sheetsに挿入されます。 次に、「Refresh options」から定期実行の設定をします。実行時刻を詳細に指定できず、4時間程度の幅の中から選ぶ必要があります。今回はSlackへの通知時刻が10時なので、それ以前の時間帯であればどの時間でも大丈夫です。 最後に「Extract」ボタンを選択して、別シートへ結果の書き出しを行います。Data Connectorで自動的に作成されたシートは直接Slackに連携できないので、一旦通常のシートへの書き出しが必要です。 Google SheetsからSlackへの連携 次にSlackへ連携させます。Google SheetsとSlack Workflow Builderを連携させるためには以下のアプリが必要なので、予めSlackのワークスペースにインストールする必要があります。 slack.com Slack Workflow Builderを起動し、新規のワークフローを作成します。トリガーは「Scheduled date & time」に設定し、毎日AM 10:00に起動するように設定します。 ここから、Google SheetsとSlack Workflow Builderを連携するための設定を入れていきます。「Add Step」を選択して、Google Sheetsからデータを取得するStepを追加します。 「Select a spreadsheet row」を選択します。もし、この時にGoogle Sheets関連のStepが見つからない場合は「Google Sheets for Workflow Builder」のインストールが必要です。 このStepの設定は以下のようにします。「Sheet」はData Connectorが自動的に作成したシートではなく、Extractをして生成したシートにする必要があります。このStepは「Choose a column to search」に設定した列の値が「Define a cell value to find」になっている行をシートから読み取ります。この例ではrow_numが1の列を読み取ることになるので、前述したクエリと併せると、BigQueryの課金額が1番多い人の情報を読み取っています。 同様に「Add Step」であと2つのStepを作成します。「Define a cell value to find」をそれぞれ2と3の値にする以外は、1つ目と同じ設定のStepにします。これにより、BigQueryの課金額が2番目と3番目に多い人の情報を読み取ります。 最後に、取得したデータをSlackに投稿するためのStepを作成します。「Add Step」から「Send a message」を選択します。 この時に「Insert a variable」をクリックすると以前のStepで読み取った値を参照できます。同じ名前が3つずつあり少し分かりにくいですが、上のものから順に1番目、2番目、3番目のStepで読み取った値を表しています。 これらの変数を埋め込み、メッセージを整えていきます。最終的には以下のようなメッセージが出来上がりました。 あとは、このWorkflowをPublishすれば毎日定期的にBigQueryの高額課金者を通知するBotが完成します。実際に動作している様子を以下に示します。 メリット・デメリット 従来のGASを使ったやり方に対する、今回の手法のメリット・デメリットをまとめます。 メリット メリットとして挙げられるのは、SQLだけを知っていればOKという点です。 GASを使う手法で必要だったJavaScriptに関する知識が今回の手法では不要になります。そのため、エンジニアの工数を消費することなく、集計結果の定期的な通知機能を実現できます。このような通知機能は1回作ったらおしまいになることは少なく、プロダクトの成長に併せて確認すべき数値が変わることもしばしばあります。最近では非エンジニアでもSQLを書ける人材が多いので、SQLさえ知っていればOKである仕組みにすると継続的にエンジニアの工数を削減できます。 デメリット 一方で、デメリットもあります。デメリットは大きく分けると2つあり、柔軟性と信頼性が劣るという点です。 まず柔軟性が劣る点について説明します。GASを使ってSlackに連携する場合はSlackのIncoming Webhook機能を使っているケースが多いかと思います。Incoming Webhookで送信するメッセージはBlock Kitに対応しているためリッチな通知ができます。今回の手法でも多少のメッセージの装飾はできますが、標準的なメッセージで可能なものに限ります。 api.slack.com また、クエリの実行時刻についても柔軟性が劣っています。Connected Sheetsの仕様により、クエリの実行時刻は最大で4時間の誤差が生じることを考慮に入れる必要があります。 さらに、通知の頻度についても柔軟性が劣ります。Slack Workflow BuilderとConnected Sheets両方の仕様により、日次よりも高い頻度では通知できません。 次に信頼性が劣る点について説明します。Slack WorkflowがGoogle Sheetsから値を読み取る時に、BigQueryで実行されているクエリの完了を待ち合わせることができません。クエリの実行が完了していない場合は前日分の集計結果をSlackに通知されてしまいます。そのため、クエリの実行タイミングとSlack Workflowの起動タイミングの間に十分なバッファを用意する必要があります。 また、クエリの実行中にエラーが発生したことを検知する方法がありません。そのため、「通知が来ない」ことによってしかエラーの検知ができません。 これらのデメリットは今後Connected SheetsやSlack Workflowの機能が充実することで解消される可能性があるので、今後に期待したいです。 まとめ BigQueryで集計をした結果を定期的にSlackに通知する機能をノーコードで作ることができました。GASで作成する場合に比べると柔軟性や信頼性では劣りますが、エンジニアの工数を使わずに通知が実現可能という点が大きなメリットです。簡単な通知Botならば非エンジニアでも作れるようになるので、データ基盤を社内の多くの職種に解放してデータ活用を更にすすめることに貢献できる機能です。 最後に ZOZOテクノロジーズでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com 【プレスリリース】ネコのマタタビ反応の謎を解明!~マタタビ反応はネコが蚊を忌避するための行動だった~ ↩
こんにちはSRE部の川津です。ZOZOTOWNにおけるログ収集基盤の開発を進めています。開発を進めていく中でCloud Pub/Subのリージョン間費用を削減できる部分が見つかりました。 今回、OSSである fluent-plugin-gcloud-pubsub-custom にコントリビュートした結果、Cloud Pub/Subのリージョン間費用を削減できました。その事例を、ログ収集基盤開発の経緯と実装要件を踏まえて紹介します。 目次 目次 ログ収集基盤の紹介 開発経緯 フロントエンドのログしか取得できない BigQuery ExportはSLAを担保されていない リアルタイムにログを保存できない 実装要件 ログ送信側の環境に依存しない共通の仕組みで実装する 転送されるログの量に応じてオートスケールする構成にする 送られてくるログをロストしない リアルタイムにログが保存される インフラ構成 Fluentd バッファリングができる アプリケーションの実装環境に依らず利用できる Cloud Pub/Sub バッファリングを行ってくれる 自動でスケーリングされる ログの送信側と受信側を疎結合にできる Dataflow Streming形式でBigQueryにInsertできる 自動でスケーリングされる ログ収集基盤の問題点 Fluentd → Cloud Pub/Sub → Dataflow 解決策 Cloud Pub/SubのSubscribeする側とPublishする側を同じゾーンにする USリージョンに揃えた場合 東京リージョンに揃えた場合 BigQueryを東京リージョンに配置した場合 BigQueryをUSリージョンに配置した場合 FluentdのOutput PluginsでPub/Subのリージョンを指定できるようにする OSSへのコントリビュート内容 効果検証 まとめ 最後に ログ収集基盤の紹介 はじめに、ZOZOテクノロジーズにおけるログ収集基盤を紹介します。 開発経緯 ZOZOテクノロジーズではZOZOTOWNにおけるログを収集する基盤に課題があるため、新たな基盤の開発を進めています。 現在のログ収集は、サイト利用者のアクセスパターン等の分析をするために、Google Analyticsを利用しています。収集したデータはBigQuery Exportを介してBigQueryに保存されます。 しかし、Google Analyticsを利用する際、下記3点の問題がありました。 フロントエンドのログしか取得できない BigQuery ExportはSLAを担保されていない リアルタイムにログを保存できない フロントエンドのログしか取得できない Google Analyticsはフロントエンドのログしか取得できません。そのため、バックエンド側のログが必要な場合でもGoogle Analyticsでは検知されず収集できません。 バックエンド側のログをGoogle Analyticsで利用するためには、一度フロントエンド側にバックエンド側のログを送信する必要があります。 BigQuery ExportはSLAを担保されていない 現在、Google Analyticsで収集したログを利用する際はBigQuery Exportの機能を使いBigQueryへログを転送しています。しかし、下記の資料からGoogle AnalyticsのBigQuery Exportでは、SLAが担保されていないことがわかります。そのため、BigQuery Exportの機能に障害が発生した場合、復旧時間は保証されません。実際にBigQuery Exportで障害が発生し数時間分のログが欠損したケースが過去にありました。 support.google.com リアルタイムにログを保存できない 分析の結果は推薦や検索へ用いるため、ログをリアルタイムに利用したい要望があります。 Google AnalyticsはBigQueryのStreaming Exportを利用できます。しかし、内部ではBigQuery Exportを利用しています。そのため、BigQuery ExportはSLAが担保されておらず、障害が発生する場合を想定すると可用性に欠けます。 以上の問題を解消するため、バックエンドを含めZOZOTOWNにおいて発生するログを集約し、リアルタイムにログを収集できる基盤を構築することにしました。 実装要件 ログ収集基盤を構築するにあたり、以下の4つの要件があります。 ログ送信側の環境に依存しない共通の仕組みで実装する 転送されるログの量に応じてオートスケールする構成にする 送られてくるログをロストしない リアルタイムにログが保存される 各要件について説明します。 ログ送信側の環境に依存しない共通の仕組みで実装する ログを送信するサーバは様々な環境で動いています。例えばAWSやGCP、オンプレミス環境です。また環境によってOSや使用しているアプリケーションのバージョンや言語も異なるので、様々な環境で同じ挙動をする共通の仕組みが必要です。 転送されるログの量に応じてオートスケールする構成にする ZOZOTOWNのアクセス数は時期や時間帯によって大きく変化します。日中の時間帯はアクセス数が多いのに対し、深夜の時間帯のアクセス数は多くありません。また年末年始のセールや人気ブランド商品の発売等、急激にアクセス数が伸びる場合もあります。アクセスの増減に対してオートスケールしない構成の場合、料金が余分に発生してしまったり、収集するログを捌ききれず遅延が発生してしまいます。 このような問題を防ぐために、アクセス数が少ない場合はサーバ台数を自動的に減らし、逆にアクセス数が多い場合はサーバを自動的に増やすような構成にします。 送られてくるログをロストしない ログがロストする状況を考えると、ネットワークの影響でデータの送信が止まった際が考えられます。そのため、各インフラリソースを利用する際は、バッファリングを行いデータのロストを防ぐ必要があります。 リアルタイムにログが保存される ログ収集基盤で取り扱うログは、アイテムの検索や推薦で利用されることが想定されます。 検索や推薦で求められるリアルタイム性は最短で1分、最長で60分です。今回ログ収集基盤に送られるログのメッセージ数はおよそ20,000msg/sです。よって送られてくる20,000msg/sのデータを60分以内に保存できる仕組みが必要です。 インフラ構成 ログ収集基盤のインフラ構成は下記の図の通りです。 各リソースを採用した理由を順に説明します。 Fluentd Fluentdの利点は以下の点です。 バッファリングができる アプリケーションの実装環境に依らず利用できる バッファリングができる Fluentd側でバッファリングする目的はCloud Pub/Subに障害が起こった際や、ネットワークの影響でCloud Pub/Subへログが送れなかった際にロストする問題を防ぐためです。Cloud Pub/Subの可用性は月間99.95%以上です。詳細についてはGoogleの Pub/Sub Service Level Agreement に記載されています。 このSLAを基にダウンタイムを考えるとおよそ20分です。20分間のダウンタイムが発生した場合、バッファリング無しだとその20分間のログデータがロストしてしまいます。このロストは許容できないため、ログの送信側であるFluentdでバッファリングを行う必要があります。 アプリケーションの実装環境に依らず利用できる Fluentdはアプリケーションとして独立しており、多様な環境で利用可能です。そのため、ログを出力するアプリケーション側の言語に依らず導入できます。アプリケーション側はログをFluentd側に送るだけで、FluentdがCloud Pub/Subへ送信してくれます。 Cloud Pub/Sub Cloud Pub/Subの利点は以下の点です。 バッファリングを行ってくれる 自動でスケーリングされる ログの送信側と受信側を疎結合にできる バッファリングを行ってくれる Cloud Pub/Subでバッファリングする目的はログのロストを防ぐためです。 Cloud Pub/Subのバッファリングの性能は、 リソースの上限 を確認すると下記のように記載されています。 Retains unacknowledged messages in persistent storage for 7 days from the moment of publication. There is no limit on the number of retained messages. If subscribers don't use a subscription, the subscription expires. The default expiration period is 31 days. 確認応答されていないメッセージは7日間保存されます。そのため、Subscriber側のDataflowに障害が起こった場合でも7日間復旧の猶予ができます。また、保存されるメッセージ数に上限はないので大量のログによるバッファあふれでログをロストする心配もありません。 自動でスケーリングされる ログの受け口がオートスケール可能であることは、実装要件で挙げたようなセールや時間帯等によるログの送信数増減でリソースの枯渇と余剰を防ぐために必要です。Cloud Pub/Subでは、Cloud Pub/Sub側が定義した「負荷」によってリソースが可変します。詳しくはGoogleの公式ドキュメントの スケーラビリティ に記載されています。 オートスケールの上限はCloud Pub/Subの 割り当て上限 を確認すると、大規模リージョンと小規模リージョンによって上限が異なります。大規模リージョンに該当するリージョンはeurope-west1、us-central1、us-east1で、小規模リージョンはそれ以外の全てのリージョンです。 大規模リージョンの場合、Publisherのスループットは下記のように記載されています。 12,000,000 kB per minute (200 MB/s) in large regions Cloud Pub/SubへのPublishに関しては1リクエスト最大10MBで、1リクエスト1,000メッセージまでまとめて送れます。 大規模リージョンにおける最大メッセージ数は上記に記載されている通り200MB/sです。今回想定しているログのメッセージサイズは1メッセージ1kBなので200,000kB/sは200,000msg/sです。想定されるメッセージ数は20,000msg/sなので、上限に引っかかることはありません。 一方、小規模リージョンの場合、Publisherのスループットは下記のように記載されています。 3,000,000 kB per minute (50 MB/s) in small regions 上限が50MBなのでおよそ50,000msg/sを送ることが可能です。 小規模リージョンと大規模リージョンを比べると、大規模リージョンの方がより多くのメッセージを捌けることが明らかです。セールによってメッセージ数が数倍に増加する場合を考慮すると、今回は大規模リージョンが適していると言えます。 ログの送信側と受信側を疎結合にできる ログの送信側をステートレスにするため、Cloud Pub/Subを導入しログの送信側と受信側を疎結合にします。 もしCloud Pub/Subを利用しない場合は、FluentdからBigQueryへ直接Insertする方法があります。BigQueryのStreaming Insertを利用することで、リアルタイムにInsertできます。この構成にするとバッファリングはBigQuery側で行うことができないので、Fluentd側のみでバッファリングを行う必要があります。 ところが、Fluentd側でバッファリングを行った場合、アプリケーションのデプロイタイミングが難しくなります。バッファリングを行っている最中はログのデータを保持していますが、下記ドキュメントによると、Fluentdのバッファリングはメモリに保存する方法とファイルへ保存する方法があると書かれています。基本的にファイルへ保存する方法を利用するので、デプロイのタイミングでログがロストする心配はありません。しかし、デプロイのタイミングにはFluentd自体のログの送信が停止してしまいます。そのため、Fluentd側でバッファリングを行うとデプロイの最中にログ送信が止まってしまいます。影響範囲を少なくするために利用者が少ない時間帯にデプロイする必要が出てきます。 このような制約により、デプロイのタイミングを制限されてしまうと、アプリケーション側のリリースに影響が出るのでCloud Pub/Subを利用しています。 docs.fluentd.org Dataflow Dataflowの利点は下記の点です。 Streming形式でBigQueryにInsertできる 自動でスケーリングされる Streming形式でBigQueryにInsertできる DataflowではStreaming形式がサポートされているので遅延を抑えてBigQueryにログを保存できます。そのため、実装要件として求められているリアルタイム性を担保できます。 自動でスケーリングされる Dataflowにおいてスケーリングが必要となる理由は、Cloud Pub/Subでスケーリングが必要な理由と同様です。 Dataflowのオートスケール機能について、公式ドキュメントの オートスケーリング機能 を確認すると、ワーカーの負荷やリソースの使用率に応じてワーカーの数は変更されることがわかります。 同様にオートスケールの上限については、Dataflowの 割り当て上限 を確認すると下記のように記載されています。 Each Dataflow job can use a maximum of 1,000 Compute Engine instances. 上記の記載はありますが、1インスタンスあたりの性能についての記載は見当たらないので、検証を実施しました。 具体的には、Cloud Pub/SubにACK処理がされていないメッセージを溜め込んだ状態にし、Dataflowを起動してBigQueryにInsertを行いました。なお、Dataflowのワーカーは下記のスペックで1台に固定しました。 CPU数:4 メモリ:15GB ストレージ:430GB このスペックはDataflowのJobを作成する際にデフォルトで割り当てられるものです。なお、Dataflowで利用できるCPUやメモリの割り当ては、 Compute Engine の割り当て に記載のあるCompute Engineのマシンタイプを指定できます。 今回はn1-standard-4のマシンタイプを利用しています。CPUはデータを並列で処理したいので4つ割り当てており、メモリはCPUが4つの場合15GBと決まっているので15GBに設定しています。 Dataflowで扱うテンプレートはGoogleが提供している Pub/Sub Subscription to BigQuery テンプレート を利用しました。 以上の条件でCloud Pub/Subにデータサイズが1kBのメッセージを4,000,000件溜め込んだ状態で検証しました。 その結果、1インスタンスのスループットは約12,000msg/sでした。 今回、Cloud Pub/Subで想定されるメッセージ数は20,000msg/sです。その場合、約20,000msg/sのメッセージがDataflow側で処理されます。Dataflowでは1インスタンスで約12,000msg/s処理できるので、性能に関して問題ないことがわかりました。 よって、セール時に処理するメッセージ量が増加した場合でも処理できます。 ログ収集基盤の問題点 ログ収集基盤にはZOZOTOWNにおけるログが全て集約されます。そのため、Cloud Pub/SubにPublishするメッセージ数とBigQueryにInsertするデータ量も必然的に膨大なものになります。 データ量が大きくなる際に懸念すべき点が、各リソース間で発生するリージョン間通信です。リージョン間通信は現在のリージョンから別のリージョンへ通信が行われる際に発生する費用です。発生する料金は ネットワーク料金表 に記載されています。 今回のインフラ構成でリージョン間通信が発生するのは以下の通信です。 Fluentd → Cloud Pub/Sub → Dataflow Dataflow → BigQuery なお、「Dataflow → BigQuery」の通信はBigQueryをUSリージョンに配置する都合があるため、次の章で併わせて説明します。 Fluentd → Cloud Pub/Sub → Dataflow Cloud Pub/Subのリージョン間通信はこちらの 料金の説明 に記載されています。 The fees for internet egress and message delivery between Google Cloud regions are consistent with the Compute Engine network rates, with the following exceptions: Cloud Pub/Subのネットワーク料金はCompute Engineのネットワーク料金と同じ料金体系です。そのため、Cloud Pub/SubのメッセージをPublishする側とSubscribeする側のリージョンが別の場合、 ネットワーク料金表 の料金体系が適用されます。 課金される料金は下記のトラフィックの種類によって分類されています。 出典: ネットワーキングのすべての料金体系 | VPC | Google Cloud 内部IPかつ同じゾーンの場合は料金が発生せず、外部IPや異なるリージョン間の通信では料金が発生します。 今回利用するリージョンは、BigQueryの配置先が東京リージョンかUSリージョンになるので、必然的に東京リージョンかUSリージョンの2パターンです。料金が発生する条件はCloud Pub/SubのメッセージをPublishする側とSubscribeする側のリージョンが別の場合が条件です。つまり、Fluentd側とDataflow側を東京リージョンかUSリージョンのどちらかに統一しない場合に料金が発生します。例えばDataflow側をUSリージョン、Fluentd側を東京リージョンに配置する場合、下記の大陸間通信のトラフィックで料金が発生します。 出典: ネットワーキングのすべての料金体系 | VPC | Google Cloud 上記の通り$0.08/GBの料金が発生します。 今回仮定されるCloud Pub/Subへのメッセージサイズは、1メッセージ1kBです。Fluentdから送信されるメッセージ数を20,000msg/sと仮定すると1秒間で0.02GB転送されます。月で換算するとおよそ50,000GBのデータが転送されます。つまり、1か月に必要なリージョン間費用は$4,000です。金額換算すると無視できる額ではありません。 解決策 Cloud Pub/Subのリージョン間通信を抑えるために、下記2つの解決策を考えました。 Cloud Pub/SubのSubscribeする側とPublishする側を同じゾーンにする FluentdのOutput PluginsでPub/Subのリージョンを指定できるようにする Cloud Pub/SubのSubscribeする側とPublishする側を同じゾーンにする 同じゾーンにリソースを配置する際の選択肢は、Fluentdを動かすサーバとDataflowを動かすサーバをUSリージョンに置くパターンと東京リージョンに置くパターンです。それぞれのパターンを考えてみます。 USリージョンに揃えた場合 Cloud Pub/SubのSubscribeする側とPublishする側をUSリージョンに揃えた場合、リージョン間の料金は発生しません。 しかし、ZOZOTOWNの利用者は日本国内からのアクセスがほとんどです。そのため、USリージョンにインスタンスを配置すると大陸間の通信が発生します。その結果、東京リージョンへ配置する場合に比べ、距離的な問題で遅延が増加します。 下記の記事によると、アメリカ西海岸までの通信ではおよそ100msの往復遅延が発生します。 xtech.nikkei.com つまり、Fluentd側をUSリージョンに配置する場合、ログを送信するアプリケーションもUSリージョンに配置する必要があり、アプリケーション側で100msの遅延が発生します。100msの遅延はユーザ体験に影響が出るレベルであるため、この遅延は防ぐべきです。 東京リージョンに揃えた場合 東京リージョンに揃えた場合、前述のような距離に起因する大きな遅延は発生しません。 しかし、リージョン間の料金はBigQueryの配置が東京リージョンかUSリージョンかによって変わります。このBigQueryの配置先の選択肢を比較してみます。 BigQueryを東京リージョンに配置した場合 BigQueryを東京リージョンに配置する場合のメリットは、リージョン間通信が発生しないことです。 デメリットは、BigQueryの利用料金の単価がUSリージョンよりも高くなる点です。BigQueryの 料金表 で東京リージョンとUSリージョンを比較すると下記の金額差があります。 オンデマンド分析の料金(USD/TB) ストレージの料金(USD/GB) 東京 6.00 0.023 US 5.00 0.020 オンデマンド分析の料金やストレージの料金を比較するとUSリージョンの方が安いです。 BigQueryをUSリージョンに配置した場合 BigQueryをUSリージョンに配置するメリットは2つあります。 BigQueryの利用料金の単価が安い BigQueryの新機能を早期に使える BigQueryの新機能はUSリージョンから順にリリースされることが多いです。そのため、USリージョンに配置することで新機能の早期利用が可能です。 デメリットは、BigQueryをUSリージョンに配置した場合は「Dataflow → BigQuery」間でリージョン間通信が発生することです。発生するリージョン間費用は「東京 → US」間の通信なのでCloud Pub/Subのリージョン間費用と同等の料金が発生します。 その結果、BigQueryをUSリージョン、BigQuery以外のリソースを東京リージョンに揃えた場合は料金を削減できません。 以上の結果から、BigQueryの利用単価が安い点と新機能が早期に利用できる点でUSリージョンへ配置することにしました。 FluentdのOutput PluginsでPub/Subのリージョンを指定できるようにする Cloud Pub/Subのリージョン間費用をなくす方法として、Cloud Pub/SubにメッセージをPublishする場合にendpointを指定する方法があります。 下記のドキュメントより、Cloud Pub/Subのサービス自体はグローバルなサービスですが、リージョン毎にendpointが用意されていることがわかります。endpointを指定しない場合はglobal endpoint https://pubsub.googleapis.com へリクエストが送られます。このglobal endpointにリクエストが送られると、Cloud Pub/Sub側が自動的にリクエストを送った場所の近くのリージョンのendpointへルーティングします。その仕様を回避させるために、Cloud Pub/Subのメッセージを受け取る側のリージョンと同じリージョンのendpointを直接指定することでリージョン間の料金を発生させなくできます。 ただし、FluentdをGCP内のリソースに構築しendpointを指定した場合は別途料金が発生します。 今回利用するインフラ構成のFluentdを東京リージョンのインスタンス上に構築した場合を考えてみます。この状態でCloud Pub/Subのendpointを指定した場合、指定したendpointのリージョンへPublishされます。ここで「東京 → US」間の通信費用が発生します。このリージョン間の通信費用もCloud Pub/Subのリージョン間通信と同等の料金が発生します。そのため、endpointを指定してCloud Pub/Subのリージョン間費用をなくす方法はGCP内のネットワーク外からCloud Pub/SubへPublishする場合のみ有効です。 cloud.google.com 今回、FluentdでCloud Pub/SubにPublishする部分は fluent-plugin-gcloud-pubsub-custom を利用することにしました。しかし、このプラグインではCloud Pub/Subへログを送る際にパラメーターでendpointを指定できませんでした。 つまり、このプラグインでパラメータによりログを送信する際のendpointを指定できるようになれば、Cloud Pub/Subのリージョン間費用をなくすことができると言えます。 以上の検討結果より、OSSとして公開されているFluentdのプラグインであるfluent-plugin-gcloud-pubsub-customを修正することにしました。 OSSへのコントリビュート内容 実際に改修を加え、OSSへコントリビュートしていきます。 まず、fluent-plugin-gcloud-pubsub-customを利用する際に、Fluentdのconfigに対してパラメータでendpointを指定できるようにします。次に、内部で利用されているRubyのCloud Pub/Sub ClientからPublishする際にも、endpointを指定してPublishできるようにします。 なお、Cloud Pub/Subの ドキュメント から new でオブジェクトを生成する際にendpointを引数で渡すことが可能です。 そのため、Fluentdでendpointのパラメータを定義した後、newの引数に定義したendpointを渡すことで実現できます。 実際に改修を加えたPull Requestは下記の内容です。 github.com このPull Requestの内容を簡単に説明します。 Configuration Parameter Types にあるconfig_paramを利用することでFluentdのconfig内で扱えるパラメータを定義できます。これを利用し、今回は下記のように定義しました。 ruby config_param :endpoint, :string, :default => nil config_paramのData Typeは String なので、定義した値がインスタンス変数のendpointに格納されます。 あとは Google::Cloud::Pubsub.new をしている部分にendpointのパラメータを渡すだけです。 Google::Cloud::Pubsub.new をしている部分が下記のinitializeメソッドの部分なので、このClassを利用している部分に先程定義したインスタンス変数を渡すように修正しました。 github.com 以上の修正でFluentdからCloud Pub/SubへPublishする際にendpointを指定できるようになりました。 効果検証 Pull Reqestがmasterにマージされ、実際にリージョン間の費用が抑えられているかの検証を実施しました。 検証として2MBのメッセージをローカルで立てたFluentdから、Cloud Pub/Subへ2,048回送信しました。つまり、合計4GBのデータがCloud Pub/Subへ送信されます。 endpointを指定しない場合はリージョン間通信が発生するので1GBにつき$0.08発生します。合計で約$0.32の料金が課金されます。 一方、endpointを指定する場合はリージョン間通信が発生せず、同一リージョン間で通信が行われるので ネットワーク料金表 に記載のある下記の料金体系が適用されます。 出典: ネットワーキングのすべての料金体系 | VPC | Google Cloud 上記の記載から無料であることがわかります。 インフラ構成は前述のログ収集基盤と同じ構成で構築し、Fluentdのendpointを指定する場合と指定しない場合のリージョン間の費用を確認しました。 まず、endpointを指定しない場合の結果は以下の通りです。 次にendpointを指定する場合の結果は以下の通りです。 上記の結果より、endpointを指定しない場合の料金は Inter-region data delivery from Asia Pacific to North America の部分で料金が発生していることがわかります。価格はリージョン間費用が$0.08/GBなので、4GB送信されているので合計$0.32です。 一方、endpointを指定する場合はリージョン間の通信は発生しないので Inter-region data delivery from Asia Pacific to North America の部分の料金は発生していません。代わりに Intra-region data delivery に対して送信したデータ量が記載されています。こちらの項目は同一リージョン間の通信に発生する項目です。料金は$0なので4GBのデータ送信は特に料金が発生しません。 以上の結果より、Pull Requestで修正した内容により、コスト削減が実現されていることを確認できました。 まとめ FluentdのPluginであるfluent-plugin-gcloud-pubsub-customにendpointを追加で指定できるようにOSSを修正しました。また、実際に修正した機能を使ってリージョン間費用が発生しないことも確認できました。 その結果、プロジェクトにかかる費用を大きく抑えることができました。より低コストなログ収集基盤を提供できます。 OSSへのコントリビュートは初めてだったので良い経験になりました。OSSのコードを読むという点でも勉強になったので、今後も積極的にOSSへコントリビュートを行っていきたいと思います。 最後に ZOZOテクノロジーズではより良いサービスを提供するための基盤を開発したい仲間を募集中です。以下のリンクからご応募ください。 tech.zozo.com
こんにちは、SRE部の谷口( case-k )です。 本記事では、EC2 Image Builderを使いRedashの運用改善を行った事例をご紹介します。運用しているRedashについてご紹介し、その後、Redashの運用課題に対してEC2 Image Builderでどのように解決したかTipsも踏まえご紹介します。 余談ですが全国どこでも働けるようになったので沖縄に住めています(感謝!) https://press-tech.zozo.com/entry/20210118_zozotech press-tech.zozo.com 目次 目次 運用しているRedashの紹介 役割 インフラ構成 クエリ実行の流れ EC2インスタンス起動時の処理 Redashの運用課題 EC2 Image Builderによる課題解決 EC2 Image Builderの紹介 各リソースのTips 事前準備 コンポーネント レシピ インフラストラクチャ カスタムAMIの生成 イメージパイプライン EC2 Image Builderの利点・欠点 利点 カスタムAMIの手動運用が不要になる リソースをコードで管理できる EC2インスタンスの起動時間を短縮できる 欠点 エラーログの調査が大変 AMI生成までの時間が長い EC2 Image Builderが担う範囲の検討 まとめ さいごに 運用しているRedashの紹介 まず運用しているRedashの役割やインフラ構成、クエリ実行の流れ、起動時の処理についてご紹介します。 役割 ZOZOテクノロジーズでは配信基盤をインハウス化して自社で開発しています。メルマガやLINEなど複数のチャンネルに対して配信しています。 techblog.zozo.com Redashの主な役割としては「分析」と「監視」です。 分析では配信施策の状況のモニタリングや施策の効果検証をしています。また、Redashではクエリ実行結果に基づいた監視も可能なので配信状況などの異常検知やデータ連携遅延などの他サービスの監視も行っています。 インフラ構成 ZOZOテクノロジーズで運用しているRedashは、公式に提供されている Redash AMI をベースにしています。Redash AMIからインスタンスを起動すると、Web UIをホスティングするRedashサーバー、クエリの実行を担うRedashワーカーが立ち上がり、Redashを利用できるようになリます。 クエリの数が増えてもRedashワーカーがオートスケールできるよう、ALB配下にはRedash AMIから起動したEC2インスタンスを配置しています。可用性を高めるため、フルマネージドでマルチAZ構成可能なAWSのAurora(PostgreSQL)とElastiCache(Redis)を参照するようにしています。 Redashは社内ツールであり、他サービスの監視ツールとしても使われています。そのため、冗長構成により可用性の高い運用を行っています。 なお、CloudForamtionについては こちら にまとめました。 クエリ実行の流れ クエリ実行の流れを説明します。 まず、実行するクエリはWeb UIをホスティングしているRedashサーバーからElastiCache(Redis)に保存されます。 クエリの実行はRedashワーカーで行われるため、RedashワーカーはElastiCache(Redis)に保存されたクエリを取得し、BigQueryなどデータソースに対してクエリを実行します。 そして、クエリの実行結果はAurora(PostgreSQL)に書き込まれます。書き込まれたクエリの実行結果はRedashサーバーより読み出されWeb UIで確認できます。なお、クエリの実行は分散タスクキューの Celery によって非同期に行われます。 クエリ実行の流れは以下の記事が参考になりました。 speakerdeck.com EC2インスタンス起動時の処理 EC2インスタンスの起動時にはミドルウェアの接続先を変更するための処理をしています。 Redash AMIは起動時にDocker Composeでコンテナイメージをビルドします。コンテナイメージをビルドするとRedashサーバーやワーカー、ミドルウェアであるPostgreSQL、Redisコンテナが立ち上がります。その際に立ち上がるRedashサーバーやワーカーが参照するミドルウェアの接続先はの環境設定ファイルに定義されています。Redash AMIをそのまま使うと、Docker Composeで立ち上げたPostgreSQL、Redisの接続先は環境設定ファイルに定義されたものが使われます。なお、Redash AMIの起動時のビルド処理はユーザーデータには定義されておりません。 今回行いたいことはインフラ構成にて説明したような冗長構成です。EC2インスタンス2台の冗長構成にするため、ミドルウェアをEC2インスタンスの外で管理する必要があります。AWSのAurora(PostgreSQL)とElastiCache(Redis)を参照するようEC2インスタンスの起動時に環境設定ファイルを書き換えてコンテナイメージをビルドする必要があります。 そのため、ユーザーデータでAWSのCLIを使えるようライブラリをインストールし、CLIでAWSシークレットマネージャー管理下の秘密情報を取得します。秘密情報としてAuroraやElastiCacheのユーザー情報やデータソースの復号化に必要な「REDASH_COOKIE_SECRET」や「REDASH_SECRET_KEY」を管理しています。そして、取得した秘密情報とCloudFormationで作ったリソースに基づいて環境設定ファイルを生成し、コンテナイメージをビルドします。 Redash AMIには起動時にコンテナイメージのビルド処理が組み込まれています。この処理はユーザーデータで定義した処理より前に実行されるため、Redash AMIの古い環境設定ファイルに基づいてコンテナイメージをビルドします。古いコンテナイメージだとミドルウェアの接続先が正しくないため、ユーザーデータでは、Redash AMIで作られたコンテナを落としてから、新しい環境設定ファイルに基づいてビルドしています。 UserData : Fn::Base64 : !Sub | #!/bin/bash -e rm /opt/redash/env curl "https://bootstrap.pypa.io/get-pip.py" -o "get-pip.py" sudo rm /var/lib/dpkg/lock* sudo dpkg --configure -a sudo apt update sudo apt install python -y sudo python get-pip.py sudo pip install awscli sudo apt install jq -y RedashUsername=$(aws secretsmanager get-secret-value --region ${AWS::Region} --secret-id Redash/RDS/User | jq -r .SecretString) RedashPassword=$(aws secretsmanager get-secret-value --region ${AWS::Region} --secret-id Redash/RDS/Password | jq -r .SecretString) cat <<EOF > /opt/redash/env PYTHONUNBUFFERED=0 REDASH_LOG_LEVEL=INFO POSTGRES_PASSWORD=${!RedashPassword} REDASH_COOKIE_SECRET=$(aws secretsmanager get-secret-value --region ${AWS::Region} --secret-id Redash/CookieSecret | jq -r .SecretString) REDASH_SECRET_KEY=$(aws secretsmanager get-secret-value --region ${AWS::Region} --secret-id Redash/SecretKey | jq -r .SecretString) REDASH_REDIS_URL=redis://${ElasticacheClusterForRedash.RedisEndpoint.Address}:${ElasticacheClusterForRedash.RedisEndpoint.Port}/0 REDASH_DATABASE_URL=postgresql://${!RedashUsername}:${!RedashPassword}@${RDSDBClusterForRedash.Endpoint.Address}:${RDSDBClusterForRedash.Endpoint.Port}/${!RedashUsername} REDASH_FEATURE_EXTENDED_ALERT_OPTIONS= true EOF sudo docker-compose -f /opt/redash/docker-compose.yml down sudo docker-compose -f /opt/redash/docker-compose.yml up -d --build Redashの運用課題 EC2インスタンス起動時にRedash AMI側の古い環境設定ファイルに基づいたビルドと、ユーザーデータで定義した新しい環境設定ファイルに基づいたビルドをしています。2つのビルドが同時に実行されることでRedashワーカーが正常に動かず、クエリ実行結果が返ってこない事象が確認できました。そのため、Redashワーカーを正常に動かすために、EC2インスタンス初回起動時のみ手動でEC2インスタンスの再起動する運用をしていました。せっかくクエリの個数に応じてオートスケールできる仕組みにしたのに活用できずにいました。また、データ分析の他に配信状況やデータ連携の遅延などの監視にも使われているため、監視ツールとしての役割に不安がありました。 EC2 Image Builderによる課題解決 前述のRedashの運用課題はコンテナイメージのビルド処理を制御すれば改善できるため、事前にAMIを作ることで解決できます。 手動でカスタムAMIを作る場合、Redashのバージョンアップやその他リソースの変更の度にカスタムAMIを作る必要があります。加えて、運用における属人化を防ぐ意味でも全てCloudFormationで管理したい思いがありました。 そのため、CloudFormationで管理可能で、カスタムAMIの手動運用が不要なEC2 Image Builderを使うことにしました。 EC2 Image Builderの紹介 EC2 Image BuilderとはカスタムAMIの作成を自動化するサービスです。 CloudFormationによる表現も可能で、一連のAMI作成をコード管理できます。CloudFormation管理にすることで、CloudFormationで作られたリソースに基づいたAMIの生成が可能となり属人的な運用の回避にも繋がります。 ここではCloudFormationを使った各リソースのTipsをご紹介します。 各リソースのTips EC2 Image BuilderでカスタムAMIを作るときには4つの要素があります。 コンポーネント レシピ インフラストラクチャ イメージパイプライン 各リソースについてCloudFormationを使いながらご紹介します。なお、「イメージパイプライン」はRedashの運用改善では使っていません。 ここで紹介する、EC2 Image BuilderによるAMI生成の全体図は次の通りです。 Icons made by Freepik from www.flaticon.com 事前準備 事前準備としてソースイメージと、EC2 Image Builderに必要なIAMを定義します。 注意点はソースイメージに指定できるものはAWSが指定するマネージドなAMIもしくは、SSMがインストールされたカスタムAMIに限られている点です。そのため、公式に提供されているRedashのAMIをソースイメージに指定できなかったので、Ubuntuのイメージに必要なモジュールをインストールしました。 そして、EC2 Image Builderで必要なIAM権限は「EC2InstanceProfileForImageBuilder」と「AmazonSSMManagedInstanceCore」です。 また、EC2 Image Builderの内部処理としてSSMを呼び出しています。エラーについてもSSMのオートメーションページに出力されます。後述しますがSSMに出力されるエラーはデバッグが容易ではないので注意が必要です。 Icons made by Freepik from www.flaticon.com EC2ImageBuilderForRedash : Type : 'AWS::IAM::Role' Properties : AssumeRolePolicyDocument : Version : '2012-10-17' Statement : - Effect : Allow Principal : Service : - ec2.amazonaws.com Action : - 'sts:AssumeRole' Path : / ManagedPolicyArns : - 'arn:aws:iam::aws:policy/EC2InstanceProfileForImageBuilder' - 'arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore' InstanceProfile : Type : AWS::IAM::InstanceProfile Properties : InstanceProfileName : ImageBuilderInstanceProfile Roles : - !Ref EC2ImageBuilderForRedash docs.aws.amazon.com コンポーネント コンポーネントはカスタムAMI作成に必要な手順を定義したリソースです。 カスタムAMIに必要なモジュールを定義した手順に沿ってインストールします。更新したコンポーネントを反映したい場合はCloudFormation反映時に「Version: 1.0.0」の部分のバージョン番号を変更して適用します。 Icons made by Freepik from www.flaticon.com Component : Type : AWS::ImageBuilder::Component Properties Data : | name : InstallApache description : InstallApache schemaVersion : 1.0 phases : - name : build steps : - name : UpdateOS action : UpdateOS - name : RedashDir action : ExecuteBash inputs : commands : - mkdir /opt/redash - name : docker-install action : ExecuteBash inputs : commands : - sudo apt-get update - sudo apt-get install -y \ apt-transport-https \ ca-certificates \ curl \ gnupg-agent \ software-properties-common - curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - - sudo apt-key fingerprint 0EBFCD88 - sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" - sudo apt-get update - sudo apt-get install -y docker-ce docker-ce-cli containerd.io - name : docker-compose-install action : ExecuteBash inputs : commands : - sudo apt-get update - sudo curl -L "https://github.com/docker/compose/releases/download/1.26.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose - sudo chmod +x /usr/local/bin/docker-compose - sudo ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose - name : aws-cli-install action : ExecuteBash inputs : commands : - curl "https://bootstrap.pypa.io/get-pip.py" -o "get-pip.py" - sudo rm /var/lib/dpkg/lock* - sudo dpkg --configure -a - sudo apt update - sudo apt install python -y - sudo python get-pip.py - sudo pip install awscli - sudo apt install jq -y Name : redash-ami-component Platform : Linux # update version when fix the component Version : 1.0.0 docs.aws.amazon.com レシピ レシピはソースとなるイメージとコンポーネントを紐付けるリソースです。 先ほど述べましたが、ソースイメージとして指定できるのはAWSが指定するマネージドなAMI、もしくはSSMがインストールされたカスタムAMIです。 ここで定義したレシピに基づいてAMIが生成されます。なお、コンポーネントを変えた場合はレシピのバージョンも更新して反映します。 Icons made by Freepik from www.flaticon.com Recipe : Type : AWS::ImageBuilder::ImageRecipe Properties : Components : - ComponentArn : !Ref Component Name : redash-ami-recipe # parentImage only accept aws managed image or custom ami installed ssm. so can not use redash ami ParentImage : arn:aws:imagebuilder:ap-northeast-1:aws:image/ubuntu-server-18-lts-x86/2020.9.23 Version : 1.0.0 docs.aws.amazon.com インフラストラクチャ インフラストラクチャはイメージのビルドからテストまでの実行環境を定義するリソースです。 「TerminateInstanceOnFailure」を「false」に設定すると、処理の失敗時にインスタンスを終了せずに済みます。そのため、SSMのエラー内容が不十分な際に活用できます。 なお、AMIをビルドする環境はインターネットへ接続できる環境である必要があるので、サブネットを定義する際には注意が必要です。 Icons made by Freepik from www.flaticon.com InfrastructureConfiguration : Type : AWS::ImageBuilder::InfrastructureConfiguration Properties : InstanceProfileName : !Ref InstanceProfile InstanceTypes : [] Name : redash-ami-infrastructure-configuration SecurityGroupIds : [] TerminateInstanceOnFailure : True docs.aws.amazon.com カスタムAMIの生成 生成するRedashのイメージは次の通りです。 まず、レシピとイメージを定義しRedashのカスタムAMIを自動生成します。すると、ビルド用のインスタンスが起動、終了した後にテスト用のインスタンスが起動します。 所用時間として30〜60分ほどかかるので時間を短縮したい場合は「ImageTestsEnabled」を「false」に設定する手段もあります。 Icons made by Freepik from www.flaticon.com RedashAmiImage : Type : AWS::ImageBuilder::Image Properties : ImageRecipeArn : !Ref Recipe InfrastructureConfigurationArn : !Ref InfrastructureConfiguration ImageTestsConfiguration : ImageTestsEnabled : true TimeoutMinutes : 60 docs.aws.amazon.com イメージパイプライン イメージパイプラインはカスタムAMIの生成をスケジューリング実行するためのリソースです。 今回は使いませんでしたが、先ほど紹介したカスタムAMIの生成処理を定期実行する際に利用できます。他にもRedashの定期的なバージョンアップなどを自動化する際に活用できます。 Icons made by Freepik from www.flaticon.com Type : AWS::ImageBuilder::ImagePipeline Properties : Description : String DistributionConfigurationArn : String EnhancedImageMetadataEnabled : Boolean ImageRecipeArn : String ImageTestsConfiguration : ImageTestsConfiguration InfrastructureConfigurationArn : String Name : String Schedule : Schedule Status : String Tags : Key : Value docs.aws.amazon.com EC2 Image Builderの利点・欠点 EC2 Image Builderを実際に利用し、そこから得られた利点と欠点を紹介します。 利点 カスタムAMIの手動運用が不要になる カスタムAMIの手動運用が不要になったのは喜ばしい効果です。 特に頻繁にバージョンアップが必要なケースでは有益です。Redashもそうですが、バージョンアップ関連の処理に利用範囲を拡げていきたいです。 リソースをコードで管理できる カスタムAMIなどリソースがコード管理されてないと属人的な運用になってしまうので、CloudFormationを使いコードで管理できるのもメリットです。 Terraformでもサポート されています。 EC2インスタンスの起動時間を短縮できる 事前に必要なモジュールがインストール済みのAMIを使えるので、EC2インスタンスの起動が早くなります。 EC2インスタンスのユーザーデータでインストールするにはモジュールが多すぎる場合に有効活用できます。例えばEC2インスタンスで稼働させているDigdagのワーカーなどにも活用できます。 欠点 エラーログの調査が大変 エラーログを調査する際に、SSMのエラーログだけでは具体的にどこで落ちたのかわかりにくいです。 原因を特定するためにはインフラストラクチャで「TerminateInstanceOnFailure」を「false」に設定し、EC2インスタンス内からログの調査を実施する必要があります。 AMI生成までの時間が長い 上述の通り、ビルドからデプロイまで長い場合だと60分ほど時間がかかります。これは、再掲の内容ですが、ビルド用のインスタンスが起動、終了した後にテスト用のインスタンスが起動するためです。 合わせて欠点の1つ目に記載したエラーログ調査の難解さもあり、必然的に開発ライフサイクルが長くなります。 EC2 Image Builderが担う範囲の検討 前章の利点・欠点であげたように、RedashのカスタムAMIの手動運用が不要になり、運用課題を解決できました。 一方で、エラーログの調査方法とAMI生成までの時間に関しては懸念が残ります。失敗時のログの調査とAMI生成までの時間を考慮すると、リソースを変更する度にEC2 Image Builderの更新が必要になる運用は避けたいです。 そのため、EC2 Image Builderではライブラリのインストールのみ実施することにしました。環境設定ファイルやdocker-compose.ymlの生成、ビルドはEC2インスタンスのユーザーデータで行っています。 今後、Redashのバージョンアップを自動化する際には、EC2 Image Builderで動的に生成すべきですが、現時点の運用ではこのような役割分担にしました。 UserData : Fn::Base64 : !Sub | #!/bin/bash -e RedashUsername=$(aws secretsmanager get-secret-value --region ${AWS::Region} --secret-id Redash/RDS/User | jq -r .SecretString) RedashPassword=$(aws secretsmanager get-secret-value --region ${AWS::Region} --secret-id Redash/RDS/Password | jq -r .SecretString) cat <<EOF > /opt/redash/env PYTHONUNBUFFERED=0 REDASH_LOG_LEVEL=INFO POSTGRES_PASSWORD=${!RedashPassword} REDASH_COOKIE_SECRET=$(aws secretsmanager get-secret-value --region ${AWS::Region} --secret-id Redash/CookieSecret | jq -r .SecretString) REDASH_SECRET_KEY=$(aws secretsmanager get-secret-value --region ${AWS::Region} --secret-id Redash/SecretKey | jq -r .SecretString) REDASH_REDIS_URL=redis://${ElasticacheClusterForRedash.RedisEndpoint.Address}:${ElasticacheClusterForRedash.RedisEndpoint.Port}/0 REDASH_DATABASE_URL=postgresql://${!RedashUsername}:${!RedashPassword}@${RDSDBClusterForRedash.Endpoint.Address}:${RDSDBClusterForRedash.Endpoint.Port}/${!RedashUsername} REDASH_FEATURE_EXTENDED_ALERT_OPTIONS= true EOF cat <<EOF > /opt/redash/docker-compose.yml version : "2" x-redash-service : &redash-service image : redash/redash:8.0.0.b32245 env_file : /opt/redash/env restart : always services : server : <<: *redash-service command : server ports : - "5000:5000" environment : REDASH_WEB_WORKERS : 4 scheduler : <<: *redash-service command : scheduler environment : QUEUES : "celery" WORKERS_COUNT : 1 logging : driver : awslogs options : awslogs-region : ap-northeast-1 awslogs-group : redash_scheduler_logs awslogs-stream : redash_scheduler scheduled_worker : <<: *redash-service command : worker environment : QUEUES : "scheduled_queries,schemas" WORKERS_COUNT : 1 adhoc_worker : <<: *redash-service command : worker environment : QUEUES : "queries" WORKERS_COUNT : 2 nginx : image : redash/nginx:latest ports : - "80:80" depends_on : - server links : - server:redash restart : always EOF sudo docker-compose -f /opt/redash/docker-compose.yml up -d --build まとめ Redashは監視の役割も担っていたので、可用性の低い状態で他のサービスの監視をすることに不安がありました。その不安を払拭するためにも、EC2 Image Builderを導入したことで初回起動時に発生していたRedashの運用課題を解決でき、監視ツールとして可用性を高められました。 また、分析ツールとしてもクエリの実行数に応じてオートスケールが可能になりました。加えて、CloudFormationを使いコードとして管理できるので、カスタムAMIの運用負荷だけではなく属人的な運用を防ぐ意味でも役立ちそうです。 実際に試してみることでEC2 Image Builderの仕様も理解できました。同時に運用まで経験することでデバッグのやりにくさや、ビルドからデプロイまでの所要時間の課題感にも気づけました。 そこから、バージョンアップなど定期的に更新が必要な場合に相性が良い仕組みだという知見も得られました。 さいごに ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! https://tech.zozo.com/recruit/ tech.zozo.com
はじめに SRE部プラットフォームSREチームの川崎 @yokawasa です。 ZOZOTOWNではモノリシックなアーキテクチャーから、優先度と効果が高い機能から段階的にマイクロサービス化を進めています。本記事では、そのZOZOTOWNの段階的なマイクロサービス移行で実践しているカナリアリリースとサービス間通信の信頼性向上の取り組みについてご紹介します。 なお、ZOZOTOWNのリプレイス戦略ついてはこちらのスライドが参考になります。 speakerdeck.com さて、ZOZOTOWNマイクロサービスプラットフォーム(以下、プラットフォーム)はAWS上に構築しており、コンテナーアプリ基盤にマネージドKubernetesサービスであるEKSを採用しています。また、複数サービスを単一Kubernetesクラスターで稼働させる、いわゆるマルチテナントクラスター方式を採用しています。 下記イメージは、そのマルチテナントクラスター(以下、クラスター)に展開されているマイクロサービスとクライアントからマイクロサービスへのリクエストフローを表した概念図です。本記事ではこの中の青点線で囲んだ部分にフォーカスしてその取り組みをご紹介します。 ZOZO API Gatewayを軸にした段階的なマイクロサービスへの移行 本プラットフォームでは、クライアントが直接サービスと通信するのではなく、すべてのリクエストをZOZO API Gatewayと呼ばれるアプリケーションを経由してサービスにルーティングする API Gatewayパターン を採用しています。 ZOZO API GatewayはURIパスベースのルーティング機能を提供し、ルーティング先であるターゲットをまとめたターゲットグループという単位でカナリアリリースの機能を提供します。また、ターゲットへのルーティングにおいてリトライ制御、タイムアウトなど通信の信頼性を高める機能を提供します。 特定のマイクロサービス移行に際して、これらの機能のおかげで古いエンドポイントから新しいものへの切り替えに対しても、クライアントがURI変更の影響を受けることなく安定的かつ段階的な切り替えが可能になります。 下図は、 /search で始まるパスのリクエストをターゲットであるZOZO Search API PrimaryとCanaryにそれぞれ90対10で加重ルーティングするイメージです。 ZOZO API GatewayはGolangで独自実装しており、アルゴリズムや細かな動作制御パラメーター、可用性の機能などZOZOTOWNのさまざまな独自要件に対して柔軟に対応が可能です。まさに、ZOZOTOWNのマイクロサービスアーキテクチャーへの段階的な移行を支える中心的なコンポーネントと言えます。 ZOZO API Gatewayについては各機能や実装レベルの詳細が書かれた人気の記事があるので、是非ご覧ください。 techblog.zozo.com techblog.zozo.com ALB加重ルーティングによるAPI Gatewayのカナリアリリース ZOZO API Gatewayをカナリアリリースするための手法を紹介します。 ZOZO API Gatewayの前段にはApplication Load Balancer(以下、ALB)があり、クライアントからのすべてのリクエストはALBからZOZO API Gatewayにフォワードされます。ZOZO API GatewayのカナリアリリースはこのALBが持つ加重ルーティング機能を活用して実現します。そして、このALB加重ルーティング設定の自動化を実現するのが AWS Load Balancer Controller (以下、コントローラー)です。 このコントローラーをクラスターにデプロイすると、Ingressリソースに指定するパスベースのルーティングや接続ターゲットの情報に基づきALBが作成され、ALBのTargetGroupsとしてアプリケーションPodに直接ルーティングするよう、自動的にALBリスナールールを設定します。 以下、ZOZO API GatewayにおけるIngressマニフェストの設定例を紹介します。 TargetGroups部分にカナリアリリースにおける既存のサービスの zozo-api-gateway-primary と一部のリクエストを振り分けたい新しいサービスである zozo-api-gateway-canary を登録します。それぞれの比重を変更してクラスターに適用すると、Ingressリソースの更新イベントを常時モニタリングしているコントローラーにより自動的に指定された比重でALBリスナールールが更新され、ZOZO API Gatewayへのトラフィックの加重率が変更されます。 apiVersion : networking.k8s.io/v1beta1 kind : Ingress metadata : name : zozo-api-gateway-ingresss annotations : kubernetes.io/ingress.class : alb alb.ingress.kubernetes.io/target-type : ip alb.ingress.kubernetes.io/scheme : internet-facing alb.ingress.kubernetes.io/actions.forward-external-traffic : | { "Type" : "forward" , "ForwardConfig" :{ "TargetGroups" :[ { "ServiceName" : "zozo-api-gateway-primary" , "ServicePort" : "80" , "Weight" : 90 } , { "ServiceName" : "zozo-api-gateway-canary" , "ServicePort" : "80" "Weight" : 10 } ] } } spec : rules : - http : paths : - path : /* backend : serviceName : forward-external-traffic servicePort : use-annotation ALB Load Balancer Controllerのannnotation設定について詳しくは 公式リファレンス を参照ください。 Istioを活用したサービス間通信のトラフィック制御 Istio を活用したサービス間通信におけるトラフィック制御についてご紹介します。なお、本記事ではサービスメッシュの概要や、Istioそのものに関する説明はしません。 Istioサービスメッシュの導入背景について ZOZO API GatewayからマイクロサービスへのルーティングにおいてはZOZO API Gatewayのトラフィック制御機能が使えますが、マイクロサービスと他サービス(クラスター外のサービスを含む)間の通信に対しても一貫した機能を提供したいという思いがありました。 これを実現するために出てきた選択肢に以下の3つがありました。 マイクロサービス間の通信でもZOZO API Gatewayを介し、一貫したトラフィック制御機能を提供する タイムアウトやリトライ制御などの機能を提供する共通ライブラリを各アプリケーションに組み込む サービスメッシュを活用し、ソースコードを変更することなくアプリケーションPodにSidecarパターンでプロキシを注入して、透過的に機能を追加する 1については、ZOZO API Gateway独自に設定しているクライアント認証設定の手間と、ZOZO API Gatewayへの負荷を考慮すると現実的ではありませんでした。また2は、ZOZOTOWNのように利用言語やフレームワークが統一されていない多様な環境をサポートする必要がある状況下では難しさがありました。最終的に、3のサービスメッシュがもっとも現実的であるという結論に至りました。 そして、我々は次のような理由からIstioを選定して、2020年後半から検証を進めました。 サービスメッシュの管理ツールの中でも 比較的利用実績が多い 我々が分散トレーシングに利用しているDatadogが Istioとのインテグレーション をサポートしている 利用クラウド基盤に影響されず、同様のユーザー体験が実現できそうである ZOZO Aggregation APIにおける設定例 3月18日に ZOZOCOSMEやZOZOVILLAがリリース されましたが、この裏側で利用されているマイクロサービスではじめてIstioを導入しました。 このマイクロサービスはZOZO Aggregation APIと呼ばれ、いわゆるBackends for Frontends(BFF)層としての複数APIの結果を集約し、フロントエンドの仕様に特化したレスポンスを返却します。 ZOZO Aggregation APIでは、下図のようにSidecarプロキシでネットワーク接続されたサービスメッシュ内ネットワーク(以下、メッシュネットワーク)のサービス間の通信とメッシュネットワーク外にあるサービスとの通信の2パターンにおいてIstioによるトラフィック制御の設定をしています。 はじめに、メッシュネットワーク内のZOZO Aggregation APIと検索機能を提供するZOZO Search APIサービス間の通信の設定例を紹介します。 以下のサンプルは Virtual Service というルーティングの振る舞いを定義するカスタムリソースのHTTPルーティング部分ですが、ここでZOZO Search APIへの加重ルーティングの比重、タイムアウトやリトライ制御を設定します。今回の例では、上図のように新旧それぞれ90対10の加重ルーティングと、5秒タイムアウトで5xxや接続エラーに対して最大2回のリトライ制御を設定しています。なお、サービス間通信設定では他にも Destination Rule というIstioのカスタムリソースの定義が必要になりますが、ここでは省略しています。 http : - route : - destination : host : zozo-search-api.searchns.svc.cluster.local subset : zozo-search-api-primary weight : 90 - destination : host : zozo-search-api.searchns.svc.cluster.local subset : zozo-search-api-canary weight : 10 retries : attempts : 2 perTryTimeout : 4s retryOn : 5xx,connect-failure timeout : 5s 次に、メッシュネットワーク外にあるBackend APIサービスとの通信設定を紹介します。 以下のサンプルもメッシュネットワーク内サービス間通信と同じくVirtual ServiceのHTTPルーティング部分です。ここでは、6秒タイムアウトで5xxや接続エラーに対して最大2回のリトライ制御を設定しています。なお、メッシュネットワーク外とのサービス間通信設定では他にも Service Entry というカスタムリソースの定義が必要になりますが、ここでは省略しています。 http : - route : - destination : host : zozo-backend-api.zozo-sample-service.com retries : attempts : 2 perTryTimeout : 3s retryOn : 5xx,connect-failure timeout : 6s 分散トレーシング 上述の通り、本プラットフォームでは、ALBからZOZO API Gatewayへのルーティング、そこからマイクロサービスへのルーティングという通信連携があります。さらに、Istioを導入してからはサービスメッシュプロキシを通じてサービス間通信が透過的にルーティングされるため、より一層複雑性が増しています。 こういった中で、問題の発生箇所やパフォーマンスのボトルネック、信頼性の機構が期待通りに機能しているかなどをログやメトリクスのみから追うのは大変困難であることが容易に想像できます。 このような問題の解決策として本プラットフォームでは構築初期の頃から分散トレーシングを導入しており、バックエンドサービスとしてDatadog APMを活用しています。 ここでは、先日リリースしたZOZO Aggregation APIへのリクエストの処理状況を表すフレームグラフをご紹介します。ZOZO API GatewayからZOZO Aggregation APIにルーティングされ、そこから複数サービス間との通信で集約された結果がZOZO API Gatewayにより返されるまでの処理状況が一気通貫で確認可能です。 本プラットフォームにおけるDatadogを活用した可観測性の取り組みについて詳細はこちらの発表資料を参照ください。 speakerdeck.com 構成管理とCI/CD 本プラットフォームでは、インフラからアプリまでサービス環境の構成は可能な限りIaC化しており、その構築・更新はCI/CDパイプラインから行うことを基本としています。今回ご紹介した各所のカナリアリリースや、通信の信頼性のための設定についても当然ながら下図のようにCI/CDを起点としてサービス環境にロールアウトされる流れにしています。 なお、ZOZOTOWNマイクロサービスプラットフォームのCI/CD戦略に関しては、こちらの記事で解説していますので是非ご覧ください。 techblog.zozo.com ちなみに、Istioの構成管理ですが、 Istio Operator というKubernetes Operatorを利用して、IaC化とCI/CDを通じた自動ロールアウトを実現しています。IstioOperatorカスタムリソースに構成設定を定義してクラスターにデプロイすると、カスタムリソースの定義を元にインストールやアップグレード、Istio全体の設定やコンポーネントごとの設定を自動ロールアウトしてくれます。 まとめ ビックバンアプローチで全体を一気にマイクロサービスアーキテクチャーとしてリリースするケースがある一方、既存機能を動かしながら多様な環境状況を考慮しつつ段階的に移行するケースがあります。本記事では後者のケースにおいてそれを支えるためにZOZOTOWNで実践しているカナリアリリースとサービス間通信の信頼性向上の取り組みについてご紹介しました。 本記事では深く紹介できませんでしたが、ZOZO Aggregation APIやIstioについてはプロダクションリリース要件をクリアするまでにさまざまなチャレンジがありました。また、Istioは今後マイクロサービス全体にその利用広げていき、サーキットブレーカーをはじめとしたより高度な機能活用を行っていく予定です。これらについては別の記事にてその詳細をご紹介できればと思っております。 さいごに ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 tech.zozo.com
こんにちは、SRE部ZOZO-SREチームに2020年新卒入社した秋田です。普段はZOZOTOWNのオンプレミスとクラウドの運用・保守・構築に携わっています。 ZOZOTOWNのオンプレミスは17年の歴史があり、BIG-IP、FortiGate、vSphereなどの様々なベンダーの製品が稼働しています。さらに、ZOZOTOWNのサービスが大きくなるにつれてオンプレミスでの拡張も続けていました。 そのようなZOZOTOWNですが、VMware Cloud on AWSを活用することでオンプレミスとパブリッククラウドを掛け合わせた柔軟なインフラを構築しています。実際に、昨年から大規模なセール時に、オンプレミスを拡張するのではなくVMware Cloud on AWSを活用したサーバー増強をしています。 本記事では、ZOZOTOWNのオンプレミス環境の自動化で利用しているAWXについて、チーム内での活用方法や運用方法を実際のセールの事例を用いて紹介します。なお、その一環でOSSとして action-ansiblelint の公開も実現しています。 AWXとは まず初めにAWXについて紹介します。AWXは、AnsibleをWebアプリケーションで管理し、REST APIやタスクエンジンを提供しているOSSです。 また、AWXは商用版のRed Hat Ansible Tower(以下、Ansible Tower)のアップストリームプロジェクトです。 AWXとAnsible Towerの大きな違いとしては、専門的サポートが受けられる点や安定かつ安全に利用できる点です。AWX/Ansible Towerでは、日本語の ドキュメント も提供されており、日本のユーザーはインストールガイドやユーザーガイドなど様々な情報を容易に得ることができます。 github.com 私たちのチームでは、コスト面やAnsibleの利用経験が浅いことを考慮し、商用版のAnsible TowerではなくOSSのAWXを利用しています。 取り組み以前のAWXに関する課題 今回紹介する取り組み実施前は、AWXを導入した前任者が別チームに異動したこともあり、いくつかの課題がありました。 Ansible Playbookがローカル管理されていた 確立された運用方法がなかった 各課題についてどういったアプローチをとったか説明します。 課題1:Ansible Playbookがローカル管理されていた オンプレミスではAWX検証環境とAWX本番環境の2つの環境があり、それぞれでコードも違う状態でした。AWX検証環境とAWX本番環境のコードは同じ状態が望ましいため、AWX検証環境のコードをAWX本番環境のコードに統一することとしました。 そして、ローカルで管理されていたAnsible Playbookのコードは、GitHubへ移行することにしました。しかし、チームにおけるGitHubの利用ルールや使い方が定まっていなかったので、同時に策定する必要がありました。 まず、新規作成したリポジトリでAWX検証環境用のブランチ(例:dev)とAWX本番環境用のブランチ(例:prd)を用意し、それぞれGitHub上にコードを上げました。AWX本番環境用のブランチをAWX検証環境用のブランチにマージすることで、差分があった箇所はAWX本番環境のコードに統一できました。 また、 git-flow と GitHub Flow を参考にチームでGitHubの利用ルールを作成しました。利用ルールと同時にGitHubの使い方についてもまとめました。これは、今後、都度修正していく前提で作成しています。 利用ルールとして定めたものを一部紹介します。 mainブランチは常に本番稼働・実行できる状態にする 作業用ブランチをmainブランチから作成する 作業用ブランチは定期的にプッシュする プルリクエストによるレビューを必須とする プルリクエストでレビューが完了したらmainブランチへセルフマージする releaseブランチを用いて本番リリースする reset、cherry-pickはしない ルールをさらに具体化するために、使い方として以下のような流れをまとめました。 mainブランチから作業用ブランチを作成して、開発する 開発が完了したら、ローカル上で作業用ブランチに作業内容を日本語で簡潔に記載したコミットメッセージを添えてコミットし、GitHub上にプッシュする プルリクエストを作成する レビュー内容に応じて必要があればコードを修正して再度コミットとプッシュする 再度コードレビューをしてもらいアプルーブであれば、mainブランチにセルフマージする ルールや使い方をまとめておくことで、チームメンバーにGitやGitHubの経験に差があったとしても、GitHubを使うことやGit操作に対してのハードルを下げることができます。 課題2:確立された運用方法がなかった 前任者が書いた構築・運用に関するドキュメントがあったので、そのドキュメントを参考に以下の方針を立て、運用方針の策定とドキュメント化を実施しました。 検証・本番環境ごとのAWX作業手順 Ansible Playbookの記述方法 秘匿情報の扱い方 AWXのメンテナンス方法 各方針について、以下で説明していきます。 検証・本番環境ごとのAWX作業手順 これまではAnsible Playbookがローカル管理だったこともあり、AWX検証環境とAWX本番環境の作業手順は以下のようになっていました。 ローカルにProjectから参照できるAnsible Playbookを配置 AWX上でテンプレートの作成 テンプレートの実行、エラーがあれば修正 AWX本番環境では、AWX検証環境で作成したものをコピーして同様に作業していました。この方法では、AWX検証環境で検証はしているとはいえコードのAnsible Playbookで間違いがあった場合には、AWX本番環境でそれを実行して事故を起こしかねない状態でした。 Ansible Playbookの管理をGitHubへ移行すると同時に、作業手順と利用方法を見直しました。 まず初めに、GitHubをAWX検証環境とAWX本番環境に連携する設定をしました。具体的には、AWXのProjectのSource Code Management(以下、SCM)の機能を利用してGitHub上のAnsible PlaybookをProjectに反映させます。Projectの設定は、以下のドキュメントを参考にしています。 docs.ansible.com 見直したAWX検証環境とAWX本番環境での作業手順が以下の通りです。 AWX検証環境での作業手順 AWX検証環境用ブランチを最新の状態にする AWX検証環境用ブランチから作業用ブランチを作成する Ansible Playbookを作成し作業をする AWX検証環境で利用するためにコミットとプッシュを行う AWX検証環境でテンプレートを作成し、トライ&エラーを繰り返して開発を進める AWX検証環境で検証が完了後、検証環境用ブランチに対してプルリクエストを作成する プルリクエストをレビューしてもらい問題なければ検証環境用ブランチにセルフマージする プロジェクトを更新し、テンプレートのSCMブランチを削除する レビューを必須とすることでAnsible Playbookの確認をチームで行い、作業ミスを減らすことができます。また、AWX上ではSCMの機能を使ってテンプレートを以下のように設定し、5.のステップを作業用ブランチで実行できるようにしています。 AWX本番環境での作業手順 AWX検証環境用ブランチにマージされたタイミングで、GitHub ActionsによりAWX本番環境用ブランチへのプルリクエストが自動作成される 作成されたプルリクエストをセルフマージする AWX本番環境でテンプレートの作成、実行する 検証とレビューで問題ないことが保証されているため、セルフマージするようにしています。AWX本番環境では、AWX検証環境で作ったテンプレートをコピーするテンプレートを用意しており、同じものを作成する手間を省いたりしています。 Ansible Playbookの記述方法 これまでは、ローカル管理されていたこともあり、自由にAnsible Playbookが記述されていました。GitHub管理に移行したので、これを気にLinterを用いてAnsible Playbookの統一化することにしました。Ansibleでは、 ansible-lint がLinterとして提供されています。 GitHub Actionsでプルリクエストにある差分のAnsible Playbookに対してLinterを実行するようにしています。 reviewdog を用いることでプルリクエストにある差分のAnsible Playbookに対してLinterの実行が可能です。 reviewdogは、Linterの結果をプルリクエストのコメントに出力したりできます。詳しく知りたい方はREADMEや以下のドキュメントを参考にしてください。 haya14busa.com reviewdogのコミュニティでは、GitHub Actionsで利用できるように多くのエンジニアが、それぞれの言語向けのLinterのActionを作成しています。 そこで、ansible-lint用のAction、 action-ansiblelint を作成しました。 GitHub Actionのコードと実行結果を以下に示します。 name : Check Source Code on : [ pull_request ] jobs : ansible-lint : name : runner / ansible-lint runs-on : ubuntu-latest steps : - uses : actions/checkout@v2 - uses : actions/setup-python@v2 with : python-version : 3.6 - name : Execute Ansible Lint uses : reviewdog/action-ansiblelint@v1.2.1 with : github_token : ${{ secrets.github_token }} reporter : github-pr-review 構文に間違いがあるとプルリクエストにエラー箇所をコメントしてくれるように設定しています。 OSSの開発については、弊社では OSSポリシー があり、スムーズに開発・公開でき、とても良い経験ができました。action-ansiblelint公開後はOrganizationをreviewdogに移す対応を行い、メンテナンスを定期的に行っています。 techblog.zozo.com action-ansiblelintを作成したことで、誰でも簡単に利用できるようにする、かつコードの統一化といった最初の目的を果たすことができました。 秘匿情報の扱い方 当然のことですが、秘匿情報はGit上で管理せずAWXの認証情報で管理しています。認証情報ではSSHやネットワーク機器、クラウドのログイン情報などをサポートしています。 ローカル管理だった際には、認証情報でサポートされていない部分は直接Ansible Playbookに記述されていましたが、今回はその直接の記述を外す必要があります。 その際に、認証情報でサポートできない項目も出てきます。そのような時に便利なのがカスタム認証情報タイプです。 docs.ansible.com 入力設定のところでYAML形式かJSON形式で以下のような認証情報を作成できます。 fields : - id : username type : string label : Username - id : password type : string label : Password secret : true required : - username - password インジェクターの設定では以下のように記述します。 env : Sample_Password : '{{ password }}' Sample_Username : '{{ username }}' 設定すると以下のような表示になります。 作成した認証情報をAnsible Playbookで利用する場合は、Lookupプラグインを使うことで呼び出しが可能です。 - name: Set Fact set_fact: username: "{{ lookup('env', 'EXECUTION_USERNAME') }}" password: "{{ lookup('env', 'EXECUTION_PASSWORD') }}" このカスタム認証情報タイプを用いて、直接Ansible Playbookに記述していたユーザー名とパスワードを認証情報で管理できました。 AWXのメンテナンス方法 AWXのメンテナンスは、KerberosやDocker Composeを含んだ環境構築自動化のためのAnsible Playbookを用いて行います。そうすることで、新しいバージョンが出た際にも、別VMで環境を作成し検証できます。AWXの環境構築で使っているAnsible Playbookでは、インベントリーファイルの変数を使ってAWXなどのバージョンを切り替えられるようにしています。その他の部分も変数化しておくことで、柔軟な環境構築ができるようにしています。 localhost ansible_connection=local ansible_python_interpreter="/usr/bin/python3" [all:vars] # AWX version awx_version=17.0.1 # containerd.io containerd_io='https://download.docker.com/linux/centos/7/x86_64/stable/Packages/containerd.io-1.2.13-3.2.el7.x86_64.rpm' # Docker Compose Version docker_compose_version=1.26.0 # OS ## uname -s os_system=Linux ## uname -m os_architecture=x86_64 # 環境ごとに変更 server_env=dev 同時にバックアップ方法の確立も必要です。 AWX/Ansible Towerでは、APIを利用したCLIが提供されています。なお、AWXのCLIは、 pip install awxkit でインストールできます。 CLIの基本的な使い方は以下のドキュメントに記載されています。 docs.ansible.com このCLIを用いてバックアップスクリプトを作成していた際に、エクスポート機能にinventoryのhostが取得できないバグを発見しました。そこで、issueを書いて以下のバグレポート上げました。 github.com バージョン 15.0.0で対応され、エクスポート機能が利用できるようになりました。 このエクスポート機能を用いて、以下のようにエクスポートすることでバックアップを取得できます。 $ awx export > backup.json このコマンドを実行するスクリプトを作成し、Cronで毎日バックアップを取得するように実行しています。 インポートもエクスポート同様、以下のコマンドで実行できます。インポートする際の注意点は、パスワードなどの秘匿情報はエクスポートされていないのでインポートした際には、手動でパスワードなどを再設定する必要がある点です。 $ awx import < backup.json チーム内で勉強会を実施 上述した新しい運用方針を策定したので、それに伴って今までのAWXの利用方法に加えてGit/GitHubに関する勉強会を行いました。この勉強会の目的は、AWXの新しい作業手順を知ってもらいGit操作やGitHubに慣れてもらうことです。 勉強会内容は以下の内容です。 BIG-IPの操作が可能なPlaybookの作成方法 AWXのテンプレート、インベントリー、認証情報の作成方法 Git/GitHubの利用方法 AWXの検証環境と本番環境の利用方法 Red Hat社が定期的に開催しているワークショップに参加し、得られた知見はこの勉強会に反映しています。また、GitHub上にそのワークショップで利用するドキュメントが公開されているのでそちらの内容を参考にしています。 github.com 2021年の冬セール準備での実例 2021年の冬セールは、コロナ禍ということもあり、トラフィック量の予想がしづらい状況でした。そのため、去年の冬セールの2倍以上のサーバーをオンプレミスとVMware Cloud on AWSを組み合わせて準備することにしました。 新規サーバーの構築後、サービスインできる状態にするまでには、いくつかのステップが必要です。 新規作成したサーバーの設定ファイル変更 ファイル配布サーバーの設定変更 新規作成したサーバに対して現行サーバーからのコンテンツ同期 BIG-IPのノードにサーバーを追加 BIG-IPのプールにサーバーを追加 この5つのステップの中で、2.は自動化が難しい部分ですが、3.に関しては既に自動化されていました。今回の冬セールに向けて1.・4.・5.の自動化を実施することにしました。 1.の自動化は、各サーバーに決まった値を設定値として指定するものでした。そのため、設定する値のリストを用意し、AWXで各サーバーに設定しました。 一方で、4.と5.では bigip_node と bigip_pool_member モジュールを利用して新しいサーバーを追加できるようにしました。 これらの自動化により、約10時間以上かかっていた作業が約2時間ほどに短縮されました。 また、これら以外にも、AWXのインベントリーに数百台規模のホストを追加する作業があります。インベントリーの作成でクラウドプロバイダーから同期する方法もありますが、弊社の環境ではグループごとに変数などを設定していたことから既存のインベントリーに追加する必要がありました。 そこで、カスタムインベントリースクリプトを利用しました。カスタムインベントリースクリプトを利用することで独自のインベントリーソースが作成でき、特定のグループに追加ができます。 docs.ansible.com PythonやShellなどで記述し、スクリプトとして実行できます。出力をJSON形式にすることで数百台をインベントリーのホストに登録できます。ここで示すスクリプトのサンプルは以下の記事を参考に作成しました。 qiita.com スクリプトのサンプルと実行結果を以下に示します。なお、実行結果は一部省略しています。 #!/usr/bin/env python from collections import defaultdict import json class SampleInventory(object): def __init__(self): self.inventory = {} self.inventory = self.sample_inventory() print(json.dumps(self.inventory, indent=2)) def sample(self, number): # ホスト名 sample = "Sample" + str(number) # 追加する台数 sample_num = 200 samples = [] for n in range(1, sample_num+1): if len(str(n)) == 1: # Sample0001 ~ Sample0009 samplexxxx = sample + "00" + str(n) samples.append(samplexxxx) elif len(str(n)) == 2: # Sample0010 ~ Sample0099 samplexxxx = sample + "0" + str(n) samples.append(samplexxxx) else: # Sample0100 ~ Sample0200 samplexxxx = sample + str(n) samples.append(samplexxxx) return samples def sample_inventory(self): multi_dimension_dict = lambda: defaultdict(multi_dimension_dict) inventory = multi_dimension_dict() inventory["sample_group"]["hosts"] = self.sample(0) for sample in self.sample(0): inventory["_meta"]["hostvars"][sample] = {} return inventory SampleInventory() { " sample_group ": { " hosts ": [ " Sample0001 ", " Sample0002 ", " Sample0003 ", #省略 " Sample0199 ", " Sample0200 " ] } , " _meta ": { " hostvars ": { " Sample0001 ": {} , " Sample0002 ": {} , " Sample0003 ": {} , #省略 " Sample0199 ": {} , " Sample0200 ": {} } } } このスクリプトでは、ホスト名 Sample の部分が共通部分で、そこに適宜数値を結合し、その結果をJSON形式で出力するようにしています。今回のサンプルでは取り入れていませんが、vSphereのAPIを活用すればもっといい書き方ができるでしょう。 まとめ 本記事ではZOZOTOWNのオンプレミス環境の自動化で利用しているAWXについて、チーム内での活用方法や運用方法を実際のセールの事例を用いて紹介しました。 Ansible Playbookのローカル管理を、GitHubでの運用へ移行することでコードの管理が容易になりました。また、CI/CDの導入によりコードの統一も実現できました。 運用方法の策定では、新しく利用方法や運用方法に関するドキュメントを作成し、チーム内でドキュメントの展開とAWXの勉強会をすることでAWXの知識の浸透を実現させました。 最後に ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
はじめに こんにちは。気がつけば4月でZOZOTOWNに関わって9年目を迎えるSRE部の横田です。普段はSREとしてZOZOTOWNのリプレイスや運用に携わっています。 本記事ではGoogle Cloud PlatformでShared VPCを採用し全社共通ネットワークを構築した背景とその運用方法について説明します。 ZOZOTOWNとパブリッククラウド専用線 まずはZOZOTOWNとパブリッククラウドを接続する専用線について説明します。 数年前まではZOZOTOWNを支える基盤は、ほぼ全てがオンプレミス環境で稼働しており、以下の課題がありました。 システムが密結合であること アジリティの低さ これらを解決するためにパブリッククラウドを活用したマイクロサービス化が日々進んでいます。 現在パブリッククラウドはAmazon Web Services(以下、AWS)とGoogle Cloud Platform(以下、GCP)を主に利用しています。一方でオンプレミス環境でも様々な重要なシステムが稼働しています。それらの異なる環境で稼働するシステムが安定して相互通信を行えるネットワークはZOZOTOWNにとって重要な存在です。 そこで現在は各パブリッククラウドで以下の専用線接続サービスを利用しています。ここではバックアップ回線を含む利用状況を示しています。 AWS: Direct Connect 10Gbps × 6本 GCP: Dedicated Interconnect 10Gbps × 2本 どちらもオンプレミスとパブリッククラウドのネットワークを直接物理的に接続するサービスです。プライベート通信のためにインターネットを介したVPNでのネットワーク経路という選択肢もありますが、膨大なトラフィックに対する品質保証のためにAWSとGCPどちらも直接接続が可能なサービスを弊社では選択しています。オンプレミスとパブリッククラウド間の通信だけではなく、AWSとGCPなどパブリッククラウド同士の通信にも、これらのサービスとデータセンターを経由し、LANで実現させているケースが多々存在しています。 直面していた課題 ここでは、実際に直面していた2種類の課題を紹介します。 BGP設定に対する課題 上述した専用線接続サービスは対向側のオンプレミス環境で要件を満たすルーターにBGP設定を投入することで疎通可能になります。弊社ではBGP設定において以下の課題が存在していました。 BGP設定自体の複雑さ 複数チームで作業するため細かな連携が必要であること AWSの場合は導入当初こそ各VPC毎にVirtual Interfaceを払い出していましたが、現在は Direct Connect Gateway を活用しているケースが多いです。そのため、一度Direct Connect Gatewayを所有するVPCに対してVirtual Interfaceの作成とオンプレミス側でBGP設定を行えば以降は相乗りするVPCにはBGP設定は不要になります。 GCPの場合はこれまでのやり方だと新規プロジェクト作成の度にBGP設定を実施していました。さらに弊社の場合はプロダクション環境とステージング環境、開発環境など複数の環境が異なるGCPのプロジェクト上で稼働しています。そのため何か新しいプロジェクトを立ち上げる場合には複数の環境に対して設定する必要があります。 またオンプレミス環境の設定とクラウド環境の設定は異なるチームで連携して互いに作業する運用だったため、連携コストや作業ミスが発生した場合の切り分けや再設定にどうしても時間を要する課題が潜在的に存在していました。 Dedicated InterconnectのVLANアタッチ上限の課題 ある日、GCPのDedicated Interconnectで致命的な問題に直面しました。 GCPを利用する複数チームから同時期に複数の新規プロジェクトでDedicated Interconnectを利用する要望があり、設定パラメーターなどを連携して各チームが作業した際にある新規プロジェクトで以下のエラーが発生したのです。 Error: Error waiting to create InterconnectAttachment: Error waiting for Creating InterconnectAttachment: Quota 'Interconnect_ATTACHMENTS_ALL_REGIONS' exceeded. Limit: 16.0 globally 1つのDedicated Interconnectに関連付けることができるVLANアタッチメントの最大数の上限である「16」を超過しようとしたため発生したエラーでした。 公式ドキュメント でも上限の引き上げが不可能な項目となっています。 この上限に関して完全に盲点だったのは反省点です。この問題を解決しない限り新規のプロジェクトがDedicated Interconnectを利用できない状況となりました。 対応方針を模索 社内の有識者を集め対応方針を決めていきました。以下に検討した案とそれぞれのメリット、デメリットを紹介します。 Dedicated Interconnect自体を追加する案 シンプルに専用線を追加する案ですがデメリットの要素が多く選択肢からは早々に外れました。 メリット これまで通りの運用で追加が行える VPCのフルコントロール権を各プロジェクトに与えられる デメリット 敷設に様々なコストがかかる 帯域の利用状況としては余裕ある状況でのDedicated Interconnect追加はもったいない 1本追加すれば上限数が16増えるがプロジェクトの増加傾向を考えるとまた同じ悩みに直面する日は近い VPC Network Peeringを使った構成にする案 GCPの VPC Network Peering を使ってDedicated Interconnect設定済のVPCとVPC Network Peeringを行うことで専用線を利用する方法です。 こちらの構成の場合は以下の設定が必要になります。 HubとなるVPCからオンプレミス側へ各プロジェクトのVPC CIDRをアドバタイズ VPC Network Peeringを使いハブとなるVPCと各プロジェクトのVPCを接続 custom route設定でオンプレミス環境のネットワーク経路を各プロジェクトのVPCへ伝播 ハブとなるVPCではカスタム経路をexport 各プロジェクトのVPCではカスタム経路をimport メリット 既存のVPCをこの接続方法に切り替える場合もVPCをそのまま残せる VPCのフルコントロール権を各プロジェクトに与えることができる デメリット 多数のプロジェクトとVPC Network Peeringを行うようになると管理が煩雑になる 接続可能なVPCの 上限が25 こちらはDedicated Interconnectを追加する案よりも魅力的でしたが、接続可能なVPC上限を考慮すると心許ない値だったため、この構成は見送りました。 Shared VPCを使った構成にする案 Shared VPC は組織内の複数のプロジェクトのリソースを共通のVPCに接続する方法です。 メリット Dedicated Interconnect利用に関するBGP設定はホストプロジェクトのみ行えば良い Firewallなどネットワークポリシーの一元管理が可能 接続可能なサービスプロジェクト数が 初期値で1000 と弊社には十分な値 デメリット サービスプロジェクト側で任意のタイミングでVPCに紐づくリソースの変更ができない 既存プロジェクトをShared VPCに移行する場合はサブネットの作り直しになる システム停止など移行方法を検討する必要がある VPCのフルコントロール権限などがなくなってしまう課題はありましたが、ガバナンスを効かせる意味でも今の環境に適しているのはShared VPCという結論となり採用することになりました。 Shared VPCの管理と運用方法 ここからはShared VPCの管理と運用を解説していきます。 Shared VPCの利用が決定した際に以下の方針を立てました。 今後Dedicated Interconnectを利用する新規プロジェクトはShared VPCのサービスプロジェクトとする 既存プロジェクトの移行を現段階では強制しない Shared VPCの管理チームはチーム横断型 1つのネットワーク管理プロジェクトとしてShared VPCは専用のGitHubリポジトリで管理 Terraformを使った構成管理とGitHub ActionsでのCI/CDを行う サービスプロジェクト追加時はサービスプロジェクトのメンバーがPull Request作成 管理チーム 現在、4名の管理チームで運用しています。管理チームの主なタスクはネットワークの採番(予め払い出した巨大なCIDRから細分化して払い出し)とPull Request作成のコードレビューです。 ネットワークの採番では Google Kubernetes Engine (以下、GKE)を使うか使わないかで提供するIPレンジを調整します。基本的にGKEの利用が無い場合はプロダクション環境やステージング環境には/20のCIDRを割当てます。開発環境やQA環境に関してはプロダクション同等のサイジングが必要無いケースも多いため、半分の/21のCIDRを割り当てます。 GKEを利用するプロジェクトの場合は必要となるIPが多くなるため/18のCIDRを割り当てるようにしています。GKEのアドレス管理に関しては 公式ドキュメント にも記載されており参考にしました。 運用方法 Shared VPCの利用依頼からネットワークリソースの作成までを図示します。 Shared VPCの管理リポジトリは各プロジェクトのリポジトリとは完全に分離されており、Shared VPCのためのCI/CDで追加したネットワークリソースを各サービスプロジェクトで指定してCompute Engineなどのリソースを作成します。ネットワークの払い出し以外はコードで完結できるようになっています。 TerraformでShared VPC環境を定義 ここからはShared VPCを構築するTerraformのtfファイルで定義される内容について解説していきます。プロダクション環境やステージング環境など複数の環境に対してCDできるリポジトリ構成としています。 . ├── .github │ └── workflows └──terraform └── gcp ├── dev │ ├── backend.tf │ ├── locals.tf │ ├── main.tf -> ../main.tf │ ├── service1.tf -> ../service1.tf │ └── service2.tf -> ../service2.tf ├── prd # dev同様のファイル構成 ├── qa # dev同様のファイル構成 ├── stg # dev同様のファイル構成 ├── main.tf ├── service1.tf └── service2.tf それぞれのファイルの設定内容を解説していきます。 locals.tf プロダクションやステージングなどの複数環境に対してCDを行うために変数を定義しています。サービスプロジェクトを追加する場合は変数の追加が必要になりますが、そちらについては後述します。なお、値は仮のものですが以下の変数を定義しました。 locals { env = " dev " # 各環境の名称 host_project = " poject-dev " # ホストプロジェクトの名称 # Dedicated Interconnectに関する各種変数を指定 interconnect_region = " asia-northeast1 " # Dedicated Interconnectのregion interconnect_url_main = " https://www.googleapis.com/dev-main " # メイン回線のURL interconnect_url_bkup = " https://www.googleapis.com/dev-backup " # バックアップ回線のURL interconnect_attachment_bandwidth_capacity = " BPS_10G " # アタッチする帯域 router_google_asn = 64512 # GCP側のASN router_peer_asn = 65000 # オンプレミス側のASN interconnect_attachment_candidate_subnets_main = [ " 192.168.0.0/24 " ] # メイン回線のBGP IPで利用するCIDR interconnect_attachment_candidate_subnets_bkup = [ " 192.168.1.0/24 " ] # バックアップ回線のBGP IPで利用するCIDR interconnect_attachment_vlan_id = 100 # Cloud RouterにアタッチするVLAN # Cloud Routerを利用するregionを定義 nat_router_regions = [ " asia-northeast1 ", " asia-east1 ", ] # マネージドサービスやServerless環境など共通の環境で利用するサブネットを定義 cidr_google_managed_services = " 192.168.10.0/24 " # マネージドサービスに割り当てるサブネット cidr_vpc_serverless_access_connector = " 192.168.11.0/24 " # Serverless環境からVPCへのアクセスを中継するコネクタ用のサブネット cidr_proxy_only_subnet = { " asia-northeast1 " = " 192.168.12.0/24 " # 内部Load Balancer利用のためのプロキシ専用サブネット① " asia-east1 " = " 192.168.13.0/24 " # 内部Load Balancer利用のためのプロキシ専用サブネット② } main.tf main.tf ではホストプロジェクト側で管理すべき以下の内容を定義しています。 Shared VPCのホストプロジェクト、VPC定義 Dedicated Interconnectを利用するための定義 networkViewerの権限を付与するサービスプロジェクトのメンバーの定義 マネージドサービスやServerless環境利用のための定義 Firewall定義 プライベートIPでLoad Balancerを利用するためのプロキシ専用サブネットの定義 Cloud NATコントロールプレーンの定義 またCDのために各環境のディレクトリ配下に(prd/stg/dev/qa)にシンボリックリンクを作成します。 サンプルコードと共に各定義の内容を解説していきます。 Shared VPCのホストプロジェクト、VPC定義 Shared VPCで利用するプロジェクトやVPCを定義します。複数regionのサービスを利用するケースがあるためルーティングモードはグローバルで設定します。 provider " google " { project = local.host_project } provider " google-beta " { project = local.host_project } resource " google_compute_network " " shared_vpc " { name = " shared-vpc-${local.env} " auto_create_subnetworks = false routing_mode = " GLOBAL " } resource " google_compute_shared_vpc_host_project " " host " { project = local.host_project } Dedicated Interconnectを利用するための定義 メイン回線とバックアップ回線にそれぞれ定義します。 BGP IPが再作成される問題に対して社内ナレッジがあったためlifecycleを使って例外設定をします。ignore_changesを利用することでTerraform上の管理しているリソースと実際のリソースに差分がある状況の変更を無視できます。 # Cloud Routerを定義 resource " google_compute_router " " shared_vpc_main " { name = " shared-vpc-main-${local.env} " network = google_compute_network.shared_vpc.id bgp { asn = local.router_google_asn advertise_mode = " CUSTOM " advertised_groups = [ " ALL_SUBNETS " ] advertised_ip_ranges { range = local.cidr_google_managed_services } } region = local.interconnect_region } # Dedicated Interconnectの定義とVLANアタッチメント # 既存のDedicated Interconnectを指定する resource " google_compute_interconnect_attachment " " shared_vpc_main " { admin_enabled = true name = " shared-vpc-main-${local.env} " interconnect = local.interconnect_url_main router = google_compute_router.shared_vpc_main.id bandwidth = local.interconnect_attachment_bandwidth_capacity candidate_subnets = local.interconnect_attachment_candidate_subnets_main region = local.interconnect_region vlan_tag8021q = local.interconnect_attachment_vlan_id # Avoid force replacement lifecycle { ignore_changes = [ candidate_subnets, ] } } # BGP IPを定義 resource " google_compute_router_interface " " shared_vpc_main " { name = " router-interface-shared-vpc-main-${local.env} " router = google_compute_router.shared_vpc_main.name ip_range = google_compute_interconnect_attachment.shared_vpc_main.cloud_router_ip_address interconnect_attachment = google_compute_interconnect_attachment.shared_vpc_main.id region = local.interconnect_region lifecycle { ignore_changes = [ ip_range, ] } } # オンプレミス側ルーターとのBGPセッションを定義 resource " google_compute_router_peer " " shared_vpc_main " { name = " shared-vpc-${local.env} " router = google_compute_router.shared_vpc_main.name region = local.interconnect_region advertised_groups = [] advertised_route_priority = 0 peer_ip_address = replace ( google_compute_interconnect_attachment.shared_vpc_main.customer_router_ip_address, " /29 ", "" ) peer_asn = local.router_peer_asn interface = google_compute_router_interface.shared_vpc_main.name } networkViewerの権限を付与するサービスプロジェクトのメンバーの定義 サービスプロジェクトのメンバーにShared VPCのnetworkViewer権限を付与するための定義です。この権限を付与しないとShared VPCで作成したサブネットの情報を各サービスプロジェクトのメンバーが参照できません。 resource " google_project_iam_member " " network_viewer " { count = length ( local.all_service_projects_members ) project = local.host_project role = " roles/compute.networkViewer " member = element ( local.all_service_projects_members, count.index ) } マネージドサービスやServerless環境利用のための定義 Cloud SQLなどのマネージドサービスやServerless環境に対してプライベートIPでアクセスするための定義します。設定詳細については公式ドキュメントもご参照ください。 Private Service Access Serverless VPC Access # マネージドサービスとのVPC Network Peering設定 resource " google_compute_global_address " " private_ip_alloc_google_managed_service " { name = " google-managed-services-${google_compute_network.shared_vpc.name} " purpose = " VPC_PEERING " address_type = " INTERNAL " prefix_length = tonumber ( element ( split ( " / ", local.cidr_google_managed_services ) , 1 )) network = google_compute_network.shared_vpc.id address = element ( split ( " / ", local.cidr_google_managed_services ) , 0 ) } resource " google_service_networking_connection " " private_service_connection_google_managed_service " { network = google_compute_network.shared_vpc.id service = " servicenetworking.googleapis.com " reserved_peering_ranges = [ google_compute_global_address.private_ip_alloc_google_managed_service.name ] } resource " google_compute_network_peering_routes_config " " private_service_access_mysql " { peering = " cloudsql-mysql-googleapis-com " network = google_compute_network.shared_vpc.name import_custom_routes = false export_custom_routes = true } # Serverless環境とVPCを接続するためのコネクタ設定 resource " google_vpc_access_connector " " connector " { name = " vpc-access-connector " region = local.interconnect_region ip_cidr_range = local.cidr_vpc_serverless_access_connector network = google_compute_network.shared_vpc.name } Firewall定義 Shared VPCに対するFirewallを定義します。設定する際のポイントは以下の点です。 Identity-Aware Proxy のように全サービスプロジェクトが利用する仕組みに関しては予め許可設定にする Shared VPCからマネージドサービスに対する通信(Egress)は予め拒否設定にする 先ほども触れましたがCloud SQLなどのマネージドサービスとプライベートIPでの通信を行う要件がありPrivate Service Accessを利用しています。Private Service Accessは作成時にPrivate Service Access用のサブネットにCIDRを指定しますが、ユーザー管理下のVPCではなくGCP管理下のVPCに作成されます。作成されたPrivate Service AccessとShared VPCをVPC Network PeeringすることでプライベートIPでの接続が可能になります。 Cloud SQLのプライベートIPでの利用時には 1つのリージョンと1つのデータベースタイプにつき最小/24のサブネットが指定したCIDR内から割り当てられる要件 が存在します。Cloud SQL側のFirewallで承認済みネットワークにプライベートサブネットを指定することができないため、何も制御を行わない場合はShared VPC内のどのようなサービスプロジェクトのリソースでもCloud SQLにインスタンスレベルでのアクセスが可能になってしまいます。 この問題を回避するために送信元側で通信を制御します。 Firewallのコンポーネント のデフォルト設定ではEgressは全て許可設定のため、上述したようにShared VPC内からマネージドサービスのサブネットに対するDeny設定を投入しています。Shared VPC内各サービスプロジェクト毎に通信要件のあるCloud SQLに対しTagまたはService AccountへEgressの許可ルールをDenyよりも高い優先順位で作成することで制御を行うことにしました。 resource " google_compute_firewall " " allow_ssh_from_iap " { name = " allow-ssh-from-iap " network = google_compute_network.shared_vpc.name priority = 65534 allow { protocol = " tcp " ports = [ " 22 " ] } source_ranges = [ " 35.235.240.0/20 " ] } resource " google_compute_firewall " " deny_all_to_private_service_access " { name = " deny-all-to-private-service-access " network = google_compute_network.shared_vpc.name priority = 65532 direction = " EGRESS " deny { protocol = " tcp " ports = [ " 0-65535 " ] } destination_ranges = [ local.cidr_google_managed_services ] } プライベートIPでLoad Balancerを利用するためのプロキシ専用サブネットの定義 プライベートIPでLoad Balancerを利用するシーンも多いためプロキシ専用のサブネットをShared VPC内に定義します。プロキシ専用のサブネットはregion毎に1つしか作成できないため main.tf で管理します。 resource " google_compute_subnetwork " " proxy_only_subnet " { provider = google - beta for_each = local.cidr_proxy_only_subnet name = " proxy-only-subnet-${each.key} " ip_cidr_range = each.value region = each.key network = google_compute_network.shared_vpc.self_link purpose = " INTERNAL_HTTPS_LOAD_BALANCER " role = " ACTIVE " } Cloud NATコントロールプレーンの定義 プライベートサブネットからインターネットに接続するためにはCloud NATを利用します。 Cloud NATはSDNな分散マネージドサービスのため以下2つの要素から定義されます。 Cloud NATコントロールプレーン Cloud NATゲートウェイ 以下の理由から main.tf でコントロールプレーンを定義します。 ゲートウェイに割り当てるIPアドレスの数などは各プロジェクトの用途により変わるため各サービスプロジェクトの定義ファイルで管理したい Cloud NATコントロールプレーン(Cloud Router)は ネットワーク毎に1つのRegionあたり5つまでという上限 がある resource " google_compute_router " " nat-router " { for_each = toset ( local.nat_router_regions ) name = " nat-router-${each.value} " region = each.value network = google_compute_network.shared_vpc.self_link bgp { asn = local.router_google_asn } } ここまでホストプロジェクト側で予め準備してきたtfファイルを解説してきました。次は各サービスプロジェクトがShared VPCを利用する場合に作成、変更するファイルについて解説していきます。 サービスプロジェクトのメンバーにより作成・変更するファイル 新規にサービスプロジェクト側でShared VPCのサブネットを利用する場合は以下のtfファイルを作成、追加変更をします。 各サービスプロジェクトの設定項目を記載する サービスプロジェクト名.tf (新規作成) サンプルとしてservice1を記載 locals.tf (追記) 順に詳細を解説していきます。 サービスプロジェクト名.tf それぞれのサービスプロジェクトで管理するネットワークリソースを定義したtfファイルを作成します。CDのため各環境のディレクトリ(prd・stg・dev・qa)配下にシンボリックリンクを作成します。 # サービスプロジェクトを定義 resource " google_compute_shared_vpc_service_project " " service1 " { host_project = google_compute_shared_vpc_host_project.host.project service_project = local.service1 [ " service_project " ] } # サービスプロジェクト(service1)で利用するサブネットを定義 resource " google_compute_subnetwork " " service1_subnet " { name = " ${local.service1.service_project}-subnet " region = local.service1 [ " region " ] network = google_compute_network.shared_vpc.id ip_cidr_range = local.service1 [ " primary_cidr " ] private_ip_google_access = true } # 作成したサブネットに対して利用するサービスプロジェクトのメンバーへ操作権限を付与 resource " google_compute_subnetwork_iam_member " " service1 " { for_each = toset ( local.service1.service_project_members ) project = google_compute_shared_vpc_host_project.host.project region = google_compute_subnetwork.service1_subnet.region subnetwork = google_compute_subnetwork.service1_subnet.name role = " roles/compute.networkUser " member = each.value } # 組織ポリシーでサブネットとサービスプロジェクトを紐づける resource " google_project_organization_policy " " service1 " { project = local.service1 [ " service_project " ] constraint = " compute.restrictSharedVpcSubnetworks " list_policy { inherit_from_parent = false allow { values = [ " projects/${local.host_project}/regions/${google_compute_subnetwork.service1_subnet.region}/subnetworks/${google_compute_subnetwork.service1_subnet.name} " ] } } } # Cloud NATゲートウェイに割り当てるIPを定義 resource " google_compute_address " " service1_nat_ip " { name = " ${local.service1.service_project}-nat-ip " region = local.service1 [ " region " ] } # Cloud NATゲートウェイを定義 resource " google_compute_router_nat " " service1_nat_gateway " { name = " ${local.service1.service_project}-nat-gateway " router = google_compute_router.nat - router [ google_compute_subnetwork.service1_subnet.region ] .name region = local.service1 [ " region " ] nat_ip_allocate_option = " MANUAL_ONLY " min_ports_per_vm = 64 # 状況に応じて変更する nat_ips = [ google_compute_address.service1_nat_ip.self_link ] source_subnetwork_ip_ranges_to_nat = " LIST_OF_SUBNETWORKS " subnetwork { name = google_compute_subnetwork.service1_subnet.self_link source_ip_ranges_to_nat = [ " ALL_IP_RANGES " ] } log_config { enable = true filter = " ALL " } } ポイントは 組織ポリシーの制約 です。サブネットとサービスプロジェクトを紐づけることができます。この組織ポリシーで複数のサービスプロジェクトに対して権限を持つユーザーが誤って意図しないサービスプロジェクトにリソースを作成してしまうことを制御できます。 locals.tf への追記 先ほど解説した locals.tf に サービスプロジェクト.tf ファイルで利用する変数を追記していきます。 # 各サービスプロジェクトで利用する変数をlocals.tfに追記して定義する service1 = { service_project = " service1-${local.env} " service_project_id = " service1-${local.env} " region = " asia-northeast1 " primary_cidr = " 192.168.100.0/24 " service_project_members = [ " group:service1@example.com " ] } # 全サービスプロジェクトのメンバーを追記 all_service_projects_members = distinct ( concat ( local.service1.service_project_members )) } 状況によってはサービスプロジェクトのメンバーで main.tf を編集してPull Requestを作成することもあります。 Shared VPC環境のCI/CD 最後にShared VPCリポジトリのGitHub Actionsを利用したCI/CDについても簡単に解説していきます。 各ブランチでCI/CDが行われ、異なるGCP環境に対して処理が実行されるようになっています。各ブランチで段階的にCI/CDすることで誤った設定をした場合も開発環境やステージング環境への反映後に気がつき修正が可能なため、安全なリリースができる仕組みとなっています。 ZOZOTOWNのCI/CD戦略については弊社川崎の書いた記事で詳しく紹介されておりますので是非ご覧ください。 techblog.zozo.com Shared VPC管理リポジトリでのCI/CDにより作成されたサブネット上にリソースを構築することで各サービスプロジェクトはDedicated Interconnectが利用可能な状態になります。 まとめ Shared VPCを導入したことにより直面したVLANアタッチメントの上限数の問題を回避できました。またクラウド環境、オンプレミス環境と複数のチームでの設定が必要なことから潜在的に抱えていた課題もオンプレミス側のBGPルーターから広報するネットワークが増えない限りは基本的にはGCP側の作業のみで完結できるようになりました。 一方でGCP外の内部リソース(AWSなど)との通信制御については課題もあります。マネージドサービスなどは全サービスプロジェクトが共通のサブネットを利用していますが、IPレベルでの通信制御ができないため現状はどうしても制御が必要なシーンでは送信元でアクセス先を絞ることになります。 Cloud Armor がプライベートIPでの通信に向けて適用できるようになることを期待せずにはいられません。 謝辞 本プロジェクトの進行と環境構築、そして本ブログの執筆にあたり多大なる協力をいただいた弊社 shiozaki と civitaspo 、そして sonots へこの場を借りてお礼を申し上げさせていただきます。 最後に ZOZOテクノロジーズでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
はじめに デバイス管理に携わる全国の情シス担当の皆様、日々の業務お疲れ様です。コーポレートエンジニアリング部ファシリティチームの佐藤です。いわゆる”情シス”と呼ばれる役割のチームに所属し、社内インフラ(PCやネットワーク機器)の管理・運用に携わっています。 この記事では以前ご紹介した Windows 10ゼロタッチキッティング を活用しキオスク端末(マルチアプリキオスク)を導入した取り組みを紹介します。 目次 はじめに 目次 導入背景 シンクライアント端末の検討 課題1:キッティングとメンテナンス 課題2:ヘルプサポート 課題3:Windows デスクトップ クライアントのアップデート マルチアプリキオスク端末への移行 マルチアプリキオスクで実現可能なこと Windows AutoPilotで実現可能なこと マルチアプリキオスク端末の構築 デバイス登録の確認 グループの作成 アプリの作成 AutoPilotプロファイル作成と適用 登録ステータスページ作成と適用 レイアウトXMLの作成 アプリ情報の取得 AppIDの取得 Windows デスクトップ クライアント クイックアシスト Windows の設定 インストールパスの取得 Windows デスクトップ クライアント クイックアシスト 構成プロファイル(キオスク)の作成 許可アプリ構成 デバイスのセットアップ 構成プロファイルの追加 まとめ さいごに 導入背景 新型コロナウイルス感染症の感染拡大が懸念されていますが、社内ではファシリティチームもさることながら関係各所の涙ぐましい努力の甲斐もあり、テレワーク可能な業務が日々拡大されつつあります。 私が所属するファシリティチームではテレワーク推進のために、Microsoftが提供するDaaSサービス WVD(Windows Virtual Desktop) を採用しています。 採用の主な理由は以下の通りです。 社内で導入済みのAzure ADやIntuneなどMicrosoft製品の知識や経験が既にあるため 既に契約しているMicrosoftとのE5ライセンスを活用できるため セキュリティ対策が為されている既存の認証システムを利用できるため 弊社では「Windows Virtual Desktop(Spring 2020 リリース)」を利用しています。このバージョンからAzureのGUI上で構築が完結できます。そのため検証や環境構築は比較的スムーズに完了できました。 ところが、WVD導入に際し「 接続元のデバイス 」が大きな懸念点として浮上してきました。 弊社ではOSに関わらず、すべてのデバイスで以下の条件が必須事項となっています。 MDM(Intune)に登録されていること 会社標準のウイルス対策ソフトがインストールされていること デフォルトのポリシー準拠を満たしたPCであること 上記の条件を前提とし、WVDを利用する上で業務形態・業務環境にとらわれることなく機密性の高い情報の取り扱いに対応できるように以下の条件を追加しました。 デバイスで利用できる機能を制御する(スクリーンショットやファイル作成など) ローカルディスクにファイルなどを保存させない アプリケーションを自由にインストールさせない デバイスを操作できる範囲をWVD接続のみとする そして、上記の条件を満たすための WVD接続専用デバイス の選定に取り掛かかりました。 シンクライアント端末の検討 「ローカルディスクにファイルを残さない」となると、まず候補に挙がるのが、シンクライアント端末です。 シンクライアント端末は、端末の機能を最小限にしたもので、データの保存や処理の多くを仮想デスクトップマシン側で行います。弊社が当初選定したシンクライアント端末OSは、Windows 10 IoT Enterpriseです。統合書き込みフィルター処理 (UWF) 機能を標準搭載しており、設定不要でシンクライアント環境を実現できるものです。 シンクライアント端末を WVD接続専用デバイス として検証を進めた結果、以下の課題が出てきました。 課題1:キッティングとメンテナンス 一般ユーザーと管理者を交互にログインし、必要な設定の追加や有効/無効の作業を実施しました。しかし、ほぼ手作業が中心となるため、設定ミスや設定漏れのリスクがありました。 最終的に、今回検証したシンクライアント端末の仕様だと、メンテナンスをするためには管理者でのログインが必要となるため、テレワークで利用する場合の対応が困難だと判断しました。 課題2:ヘルプサポート ユーザーに代わって遠隔でPCを操作するために、弊社ではWindows 10標準のクイックアシストを利用しています。 しかし、シンクライアント端末のOSであるWindows 10 IoT Enterpriseにはクイックアシスト機能はついていないため、遠隔でのサポートができません。そのため、テレワーク利用を前提とした場合、ヘルプサポートのためのリモートデスクトップが利用できません。 課題3:Windows デスクトップ クライアントのアップデート WVDへの接続はWindows デスクトップ クライアントを採用しています。 その際の課題点はWindows デスクトップ クライアントの定期アップデートに対応できないことです。一般ユーザーではアップデートに必要なフォルダへアクセスできず「ファイルを読み取るときにエラーが発生しました」のエラーとなり、定期アップデートが実施できない結果となりました。 マルチアプリキオスク端末への移行 上述の通り、シンクライアント端末での課題を運用でカバーすることは困難と判断し、別の案を模索しました。 模索した中で今までの知見と経験を活かせる対応策として、 Windows AutoPilotでキッティング可能なマルチアプリキオスク端末 が候補に挙がりました。 マルチアプリキオスクで実現可能なこと 従来のキオスクモードは、単一のアプリのみ実行が可能となり、一般ユーザーはそれ以外のアプリを操作できません。 しかし、Windows 10 バージョン1709以降で対応している マルチアプリキオスク は、管理者が複数のアプリを実行できるようにキオスクを作成できます。 そのため一般ユーザーが操作可能なアプリを明示的に制限することで、特定の作業のみで使用するデバイスとしてPCをセットアップできます。 そして、マルチアプリキオスクをIntuneから展開および管理することで以下の課題を解決しました。 デバイス制御 Intune登録デバイスになるため、ログインなど多くのログの収集が可能 遠隔によるワイプが可能 管理者が許可しないアプリの制限が可能 情報漏洩のリスク Intuneによる構成プロファイルにて、BitLockerによる暗号化の強制が可能 ファイルのアップロードおよびダウンロードの制御が可能 ログインアカウントをAzure ADアカウントのみに制限が可能 ヘルプサポート 許可するアプリにクイックアシストを追加することで、デバイスの不具合時は遠隔サポートが可能 Intuneにより追加のアプリや設定が管理者側で容易に可能 Windows AutoPilotで実現可能なこと Windows AutoPilotとは自社環境に適したWindows 10デバイスの初期セットアップをクラウドを介して自動的に行うサービスです。 1台ずつのOSイメージ展開とは違い、デバイス情報を事前にIntuneへ登録しておくことで個別にセットアップする必要がなくなります。そして、一般ユーザーに端末を配送し、デバイスを初回起動したタイミングで自動で企業による構成や設定、必要なアプリケーションのインストールが実行されます。 冒頭でも紹介した Windows 10ゼロタッチキッティング と同様に、今回もOEMベンダーの協力のもと、デバイス情報をIntuneにタグ付きで登録します。 Windows AutoPilotによるキッティングの流れは以下の図の通りです。 マルチアプリキオスク端末の構築 次に、Windows AutoPilotを利用してマルチアプリキオスク端末をセットアップするまでの手順を順を追って説明します。 今回構築するキオスクの構成は以下の通りです。 デバイスOS:Windows 10 Pro(バージョン 2004) 許可するログイン種類:Azure AD ユーザー 使用を許可するアプリ Windows デスクトップ クライアント クイックアシスト Windows の設定 手順は以下の図の通りです。 最終的にユーザーがログインすると以下の図のデスクトップ画面となります。 デバイス登録の確認 キオスク端末とするデバイスを購入する際、OEMベンダーにIntuneへの登録と共に グループタグ を付与するように依頼します。Intuneのデバイス登録画面に正常にデバイスが登録されていることを確認します。 グループの作成 キオスク端末を利用するユーザーのための「ユーザーグループ」とキオスク端末とするデバイスが所属する「デバイスグループ」を作成します。 今回はWindows AutoPilot用プロファイルの適用を自動化するため、デバイスグループは動的グループとして作成しメンバー条件を「グループタグ:[ KIOSK ]」とします。 作成するグループは以下の通りです。 ユーザーグループ:静的(手動でキオスク端末を利用するユーザーを追加) デバイスグループ:動的メンバーシップルール アプリの作成 キオスク端末がWVDへ接続する際に使用する Windows デスクトップ クライアント をIntuneから展開します。 下記のサイトから Windows デスクトップ クライアント の最新版をダウンロードします。 docs.microsoft.com Intuneにサインインし、「アプリ」→「追加」をクリックし、アプリの種類は「基幹業務アプリ」を選択します。 「アプリ パッケージ ファイルの選択」から事前にダウンロードしたWindows デスクトップ クライアントのmsiファイルをアップロードします。 アプリの追加画面にてアプリ名などを任意で設定します。そして、コマンドライン引数は以下のものを利用します。 /qn ALLUSERS=2 MSIINSTALLPERUSER=1 割り当て先は、事前に作成したデバイスグループを指定します。 AutoPilotプロファイル作成と適用 キオスク端末用のAutoPilotプロファイルを以下の設定で作成します。 設定項目 設定値 配置モード 自己展開(プレビュー) Azure AD への参加の種類 Azure AD 参加済み 言語(リージョン) オペレーティング システムの既定値 プライバシーの設定 非表示 アカウントの変更オプションを非表示にする 非表示 ユーザー アカウントの種類 標準 割り当て先 事前に作成したキオスク端末が所属するデバイスグループ ここで、配置モードを「自己展開モード」とする点がポイントです。なお、自己展開モードはキオスク、デジタル看板デバイス、または共有デバイスとしてWindows 10デバイスを展開する場合の設定です。 ユーザー アカウントの種類は一般ユーザーである「標準」を選択します。 登録ステータスページ作成と適用 キオスク端末用の登録ステータスページを作成します。 登録ステータスページ(ESP)は、デバイスのキッティング時にプロビジョニングの進行状況を表示する設定です。 ここではWindows デスクトップ クライアントがインストールされるまでデバイスの利用をブロックするように指定します。割り当て先は、事前に作成したキオスク端末が所属するデバイスグループとします。 レイアウトXMLの作成 キオスク端末で表示するスタートメニューのレイアウトをXMLファイルとしてエクスポートします。 まず、通常のWindows 10のPCにて、「Windows の設定」から「タブレットモード」を「オン」にします。 次に、タブレットモードである状態で、スタートメニューに必要なアプリのアイコンを配置していきます。その際に、タイルの大きさやタイトル名は任意で設定します。 配置完了後、PowerShellを管理者権限で起動し、以下のコマンドを実行してレイアウトXMLファイルをエクスポートします。 Export-StartLayout -path C:¥Layout-KIOSKDevice.xml アプリ情報の取得 次に、アプリ情報として必要なAppIDとインストールパスを取得します。 AppIDの取得 前述のレイアウトXMLの作成で配置したアプリの情報をPowerShellにて取得します。 引き続き、先程の通常のWindows 10のPCにてPowerShellを起動し、以下のコマンドを実行します。 Windows デスクトップ クライアント get-StartApps | ?{$_.name -like "remote*"} クイックアシスト get-StartApps | ?{$_.name -like "クイック*"} Windows の設定 get-StartApps | ?{$_.name -like "設定*"} 以上のコマンドの実行結果に表示される AppID を記録しておきます。 インストールパスの取得 また、各アプリのインストール先のパスも同様に記録しておきます。 2つのアプリのインストールパスを例として挙げます。 Windows デスクトップ クライアント C:¥Program Files¥Remote Desktop¥msrdcw.exe クイックアシスト C:¥Windows¥System32¥quickassist.exe 構成プロファイル(キオスク)の作成 デバイスをキオスク端末とするための構成プロファイルをIntuneにて作成します。 Intuneにサインインし、「デバイス」→「構成プロファイル」→「プロファイルの作成」を選択します。 「プロファイルの作成」ペインで、プラットフォームを「Windows 10 以降」、プロファイルの種類を「テンプレート」し、一覧から「キオスク」を選択して下部にある「作成」を選択します。 すると、構成プロファイルの作成画面が開きます。 「基本」タブにて、プロファイルの名前を任意で入力し、「次へ」を選択します。 次に、「構成設定」タブにて、以下のように設定します。 設定項目 内容 キオスク モードを選択します マルチ アプリ キオスク S モード デバイスで Windows 10 を対象とする いいえ ユーザーのログオンの種類 事前に作成したユーザーグループを選択 許可アプリ構成 「ブラウザーとアプリケーション」の追加画面にて、「Win32 アプリの追加」ボタンを押して以下の通り設定を追加します。 許可するアプリ: Windows デスクトップ クライアント アプリケーション名:Remote Desktop アプリの実行可能ファイルのローカルパス:C:¥Program Files¥Remote Desktop¥msrdcw.exe ユーザーモデルID (AUMID):<コマンドで取得したAppID> タイルサイズ:任意 自動起動:オン 許可するアプリ: クイックアシスト アプリケーション名:Remote Desktop アプリの実行可能ファイルのローカルパス:C:¥Windows¥System32¥quickassist.exe ユーザーモデルID (AUMID):<コマンドで取得したAppID> タイルサイズ:任意 自動起動:オフ 「ブラウザーとアプリケーション」の追加画面にて、「AUMIDの指定によるアプリの追加」ボタンを押して設定を追加します。 許可するアプリ: 設定 アプリケーション名:設定 AUMID/パス:<コマンドで取得したAppID> 「スタート メニューのレイアウト」にて事前にエクスポートしたレイアウトXMLファイルをアップロードします。 「Windows タスクバー」と「ダウンロード フォルダーへのアクセスを許可する」「アプリの再起動のためのメンテナンス期間の指定」は任意で設定します。 「割り当て」では事前に作成したユーザーグループを指定します。 なお、ここでデバイスグループを割り当てにすると、Azure ADユーザーでログオンできないという問題が発生します。Microsoftによると、ユーザーログオンの種類で「Azure ADユーザーグループ」を指定する場合は、プロファイルの割り当て先もAzure ADユーザーグループとする必要があるとのことです。 以上でキオスクプロファイルの作成と適用が完了します。 デバイスのセットアップ 実際にデバイスをセットアップしていきます。 PCの電源をオンにし、ネットワークに接続するとWindows AutoPilotにより自動でセットアップが開始されます。ここでは「デバイスの準備」と「デバイスのセットアップ」が実施されます。 「デバイスの準備」と「デバイスのセットアップ」が完了すると、ユーザーのログイン画面が表示されます。キオスク用ユーザーグループ内のユーザーのアカウント情報を入力して、ログインを実施します。 すると、自動で「アカウントのセットアップ」が実施されます。 セットアップがすべて完了すると、Windows 10のデスクトップ画面が表示されます。その後、一度ログオフして再度ログインします。 デバイスにログインすると、それ以降はレイアウトXMLで指定したキオスク画面が表示されるようになります。 これにより、該当デバイスはキオスク端末となり、「Windows デスクトップ クライアント」「クイックアシスト」「設定」のみが操作可能な状態となります。 またプロファイルにて「Windows デスクトップ クライアント」の自動起動をオンの設定としたため、デバイス起動と同時にアプリが立ち上がります。ユーザーはデバイス起動と同時にWVDへ接続する画面を操作することが可能となります。 構成プロファイルの追加 Intuneによるマルチアプリキオスクを採用したことで、デバイスのセットアップ後もユーザーや組織のニーズに合わせて設定のカスタマイズが可能となっています。 ここでは、Intuneの 構成プロファイル を使用して「Windows の設定」画面に表示する項目を制御する例を紹介します。 Intuneにサインインし、「デバイス」→「構成プロファイル」→「プロファイルの作成」を選択します。 「プロファイルの作成」ペインにてプラットフォームを「Windows 10 以降」とします。プロファイルの種類を「テンプレート」とし、「デバイスの制限」を選択して、下部にある「作成」を選択します。 すると、構成プロファイルの作成画面が開きます。 「基本」タブにて、プロファイルの名前を任意で入力し、「次へ」を選択します。 次に、「構成設定」タブの「コントロール パネルと設定」にて「Windows の設定」画面で表示しない項目を「ブロック」にします。割り当て先はキオスクデバイスグループとして、プロファイル作成を完了させます。 デバイスに適用されると、「Windows の設定」で表示される項目が制限されていることが確認できます。 まとめ マルチアプリキオスクの採用でセットアップ後のカスタマイズやアプリアップデート配信など管理面において柔軟に対応ができます。また、一度構築されたセットアップ手順はIntuneによって自動化されるため、作業ミスや手順漏れがないため安定した展開環境を維持できます。Windows Virtual Desktopへの接続デバイスの1つの例として、本記事がお役に立てれば幸いです。 さいごに ZOZOテクノロジーズでは、社内の課題をITの力で解決する仲間を募集中です。WindowsとMacはもちろんiPhoneやiPadなど様々なデバイスを効率よく管理と制御することにどんどんチャレンジできます! ご興味のある方は、下記のリンクからぜひご応募ください! tech.zozo.com
はじめに BtoB開発部の増田です。 BtoB開発部は、主に Fulfillment by ZOZO (以下、FBZ)の開発を担当しているエンジニアチームです。FBZの初回ローンチから間もなく3年経過しますが、サービスの拡大、拡張とともに見直すべき課題も増えてきました。日々の運用負荷の増大や、それに伴う開発効率の低下の話しを耳にする機会も増えています。そこで、今期の開発計画では、運用改善のための開発も優先度を上げて取り組むこととしていました。 一方で、新型コロナウィルスの影響もありチーム全体がリモートワークに移行して1年が経過しました。リモートワークが浸透する過程にはさまざまなコミュニケーション課題があり、上記の運用改善の施策を進める上でもコミュニケーションの円滑化が急務でした。 そのようなコミュニケーション課題の対策のひとつとして1on1に力を入れているチームも多いでしょう。この記事では、1on1の実施がエンジニアチームの生産性やパフォーマンスにどのような影響を与えるか、BtoB開発部における実績をひとつの事例として紹介します。加えて、1on1を起点としてチーム内の「ガチ対話」を増やしていくために、どのような工夫が考えられるかをまとめました。 組織サーベイの結果で示されたチーム状況の変化 まず今回の取り組みで実現されたチーム状況の変化を示します。ある時期から顕著にポジティブな変化が表れるようになりました。その変化は、組織サーベイの結果から定量的に知ることができます。 ZOZOテクノロジーズでは3か月に一度のペースで組織サーベイを実施しています。匿名アンケートの回答結果に基づき、チーム単位でのエンゲージメントを定期的にモニタリングしています。今期は、2020年6月、9月、12月の計3回を実施しており、直近の9月、12月に実施したサーベイの結果に特徴的な変化が表れていました。 着目している重要指標 組織サーベイの結果は、全部で36項目の指標で示されます。そのうち、BtoB開発部では以下の9項目を重要指標と位置付けて注視しています。 今期は、FBZのサービス開発のなかで、エンジニアひとりひとりが成長を実感できる環境づくりに取り組んできました。リモートワークでもそのような環境が実現できているかを推し量るための指標として、下記の観点で重要指標9項目をピックアップしました。 日々の業務にやりがいを感じられているか(職務、理念戦略) エンジニアとしての成長実感を得られているか(自己成長) 成長のためのチャレンジを支援できているか(支援) リモートワークの中でも良好な人間関係を築けているか(人間関係) 成果に対して納得できる評価を感じられているか(承認) 重要指標の定量的変化 9月、12月の結果を比較すると、全36項目のうち32項目で改善が見られました。重要指標9項目に着目しても、漏れなくすべての項目で大幅に改善されており、9月から12月にかけてチーム状況がポジティブに変化したことがわかります。 変化の背景 BtoB開発部にとって、昨年9月は組織変更を行ったタイミングでした。この時期を境に、1on1をテンプレート化したり、チームごとの朝会・夕会を活発化するなどいくつかのコミュニケーション改善を実施してきました。特に1on1は、リモートワークで希薄になりやすいコミュニケーションを補強するために工夫をした部分です。次章では、この1on1に関してさらに説明していきます。 1on1の定期実施で意識したこと、わかったこと もともとBtoB開発部では、前身の子会社時代から数年に渡って1on1を実施してきました。しかし、リモートワークに適応するため、実施頻度ややり方を見直す必要がありました。昨年9月以降の実施要領は以下のとおりです。 最低でも隔週で実施する(時期や状況によっては週次での実施) 1回あたりの時間は30分 振り返りと傾向分析がしやすいように箇条書きレベルでログを残す 各回の対話のテーマはメンバー側から設定する 上記要領に沿って対話のテーマをメンバーが設定する際には、下記の5つのカテゴリから設定してもらっています。 質問をしたいです 共有をしたいです 雑談がしたいです モヤモヤしています ネタに困っています 期間中、私が10人のチームメンバーと行った1on1は合計80回で、テーマ数にして118個の対話をしました。テーマをカテゴリ別に集計すると、下記のグラフのような分布です。 この集計結果と組織サーベイの結果をもとに、BtoB開発部における1on1の影響を、チームメンバーへの影響、エンジニアリングへの影響、リーダー自身への影響の3つの観点で振り返ってみます。 チームメンバーへの影響 カテゴリ別に見ると、「質問をしたいです」の割合がもっとも多く、全体の40.7%を占めていました。なかでも、事業方針や、各々の役割や期待値に関する内容が多く、リーダー側からの説明不足を痛感しました。 事業方針のような大きなテーマだけでなく、タスクアサインの背景やミーティングでは質問できなかった疑問点など、直近の出来事に関する背景確認も頻出するテーマのひとつです。リモートワーク環境下で普段よりも共有や背景説明が薄くなりがちですが、1on1で早期に情報共有の不足を検知することで、素早くフォローできるようになりました。 次に、「共有をしたいです」「雑談がしたいです」がそれぞれ19.5%、18.6%と同程度の割合でした。共有については、メンバーからの直近の進捗共有や課題共有が大多数です。それと同じくらい1on1での雑談の割合が多かったのは、リモートワークが常態化して特に顕著になった傾向です。 チームミーティングで日々メンバーと会話する機会はありますが、どうしても業務中心の会話になってしまいます。1on1で対話を深めていくには、雑談や何気ない会話から育まれる信頼関係が重要です。以前は対面の偶発的コミュニケーションによってその下地が作られていましたが、それを意識的に実施するために、あえて1on1でも雑談をメインとするケースは増えました。 組織サーベイの結果のうち、「支援」「人間関係」「理念戦略」の部分が改善した背景には、実務的な支援だけでなく雑談を含めたコミュニケーションによる関係性の向上があったと考えています。 エンジニアリングへの影響 冒頭でも述べたように、FBZのサービス拡大、拡張とともに見直すべき課題も増えてきました。日々の運用負荷の増大や、それに伴う開発効率の低下が課題になっており、今期の開発計画では運用改善のための開発も優先度を上げていました。 1on1での課題抽出は計画検討のための情報収集として有効ですが、情報収集の要素以外にも、チームの改善意欲の向上という副次的な効果がありました。 課題について対話する過程で課題認識を持っていたメンバーほど改善意欲が高まり、リーダーシップを発揮してくれるようになりました。メンバー自らが中心となり計画を立案して実践し、その結果、直近でもっとも大きな課題となっていたノイズアラート対策が完了しました。チームの生産性を改善する大きな成果でした。具体的な内容は以下の記事で紹介しています。 techblog.zozo.com 計画の中では、若手メンバーのチャレンジや興味のあるサービスの試験利用など、課題解決のなかでメンバーが成長実感を得やすくなるような工夫も盛り込まれていました。チームのエンジニアリングを考え直すきっかけにもなり、このことが組織サーベイの結果の「自己成長」を高めることに繋がりました。 リーダー自身への影響 1on1は、基本的にはメンバーのための時間としています。対話のテーマをメンバー自身で設定してもらっているのも、上長ではなくメンバーが話したいテーマにフォーカスするためです。 一方で、メンバーの成長促進や内省支援を心がけたいと意識しつつも、対話を重ねれば重ねるほど結果的にリーダー自身も内省を深めていくことになります。 メンバーが体験した達成感から成長の着想を得たり、メンバーが感じている事業方針への違和感からサービスの改善点が浮き彫りになるなど、1on1はリーダーのアクションの原動力にもなります。 その意味で、1on1はメンバーだけではなくリーダーを含む相互成長のための場であり、1on1の頻度が増えることはリーダーの成長機会の増加にも繋がると言えます。 「ガチ対話」を目指すための工夫 昨年12月、ZOZOテクノロジーズに組織開発チームが立ち上がりました。マネジメントを強化するための専門の部署が発足したことはうれしい変化です。さっそく、組織開発チーム主導のもと、1月から週1回30分の1on1が全社で必須化されるなど、新たな変化が始まっています。 組織開発チームが掲げるテーマのひとつに、 創造性を解き放つために社内の「ガチ対話」を増やす というものがありました。この半年、自分自身がマネジメントのなかで意識してきたことのひとつでもあります。メンバーの主張、願望、反論など、内面の声を表面化しやすくするにはどのような工夫が考えられるか。いま、BtoB開発部で「ガチ対話」を増やしていくために意識しているポイントが2つあります。 全員が少しずつリーダーシップを意識していく 役職や役割に囚われず、リーダーシップを持ったメンバーが増えると、チームは強くなり成果が生まれやすくなります。前述の運用改善のケースでも、メンバー自らがリーダーシップを発揮した結果として成果が生まれました。 このような事例が今後も続くようにするには、メンバーひとりひとりが考えること、実践することを繰り返していく必要があります。そのために、1on1のテーマのひとつとして、「もしあなたがリーダーだったらいま何をしますか」という対話を徐々に増やしていっています。 このテーマで対話することにより、意識が個からチームへ広がります。意識が広がる分、理解すべきことやわからないことが増えます。それをクリアにするためには何らかのアクションが必要で、そのアクションによって経験が積み増しされます。1on1でこうした循環が生まれてくると、メンバーとリーダーの思考発話の過程が「ガチ対話」へと発展しやすくなります。 感情を共有する もうひとつ意識しているのが、感情の共有です。 ◯◯を達成してくれてありがとう! ◯◯を実践してくれたことが嬉しいです! ◯◯のアクションがとてもよかったね! ◯◯をとても不安に思っています ◯◯だったのがとても残念 ◯◯について共有不足で申し訳ない など、自分自身の感情はできるだけ背景を言語化して伝えるようにしています。 リーダーが1on1を考えるとき、「傾聴」や「コーチング」などのテクニックにフォーカスしすぎてしまうことがあります。自分自身を振り返ってみても、1on1をより1on1っぽいものにするため、テクニックに偏った1on1をやってしまった経験があります。とにかく聴くこと、考えさせることを意識しすぎたあまり、禅問答のような対話に陥り「ガチ対話」とはほど遠くなっていました。 1on1を、会議よりも有意義な対話にするためには、もっと自然体で臨む必要があります。メンバーの言葉を聴き、感じた印象はストレートに伝えます。とりわけ、お互いの感情を織り交ぜながら対話するほうが伝わりやすくなります。 リーダーとメンバーがお互いの感情を伝え合い、感情面での共感や反発があって「ガチ対話」が生まれやすくなります。テクニックを身に付けていくことはもちろん重要ですが、テクニックを活かす土台作りとして、感情を共有しあうことを意識しています。 まとめ 以上、1on1のひとつの事例として、BtoB開発における取り組み紹介しました。組織サーベイの結果が改善されたことは、1on1に限らずいくつかの要素が複合的に作用し合った結果です。ただ、1on1を繰り返す過程で、チーム状況が好転していく手応えを感じることができました。改善すべき点は多々ありますが、今後も「ガチ対話」を増やすことを目指しながら成長を追求していきたいと思います。 また、組織サーベイの結果だけでなく、エンジニアとしての施策の遂行にも繋げられたことも重要な成果です。引き続き、アウトプットにつながる変化を生み出していきます。 さいごに ZOZOテクノロジーズでは、BtoB事業の拡大に取り組んでいただけるエンジニアを絶賛募集中です。これまでの自社EC支援、物流支援に加えて、今後はブランドさまの実店舗との連携を強化するための開発を推進していく予定です。 ファッション業界のDX推進に関わる開発や、ブランドさまとの共同開発プロジェクトにご興味ある方は、 こちら からぜひご応募ください! tech.zozo.com
こんにちは。MSP技術推進部の手塚( @tzone99 )です。 この記事では、エンジニア向けのツールを周囲のエンジニア以外のチームにも導入し、チームを跨いだコミュニケーション上の課題を解決した事例をご紹介します。 普段エンジニアとしてプロダクトを開発する中でも、エンジニア同士のやり取りだけで業務が完結しないケースも多いかと思います。周囲のチームとやり取りする中でコミュニケーションのずれが発生した場合の対応として、今回の事例が参考になれば幸いです。 MSP技術推進部の活動について興味のある方はこちらの記事もぜひご覧ください。 techblog.zozo.com techblog.zozo.com techblog.zozo.com techblog.zozo.com 目次 目次 背景 コミュニケーション上の課題 業務要件のMarkdown/PlantUML化 運用の初期対応 自作のLinter導入、継続的メンテナンス 結果 おわりに 背景 私の所属するMSP技術推進部は服づくりに関する技術を開発する部門です。 ZOZOTOWN内で販売されるMS(マルチサイズ)アイテムの生産プラットフォームとして、アパレル商品の企画、設計、生産に関わる業務システムを開発し、ITを活用した新しい服づくりを追求しています。 MSアイテム は最大56サイズから自分の体型に合ったサイズを選択可能、という特徴を持っており既存のサイズに囚われずいろんな体型の方にファッションを楽しんでいただけるアイテムです。 一般的な場合と同様、このような多サイズのアイテムにおいても、生産の際はサイズ毎のパターンデータ(服の設計図)を用意する必要があります。従来SMLの3サイズ程度だったパターンデータを最大56サイズ分用意するためパターン設計の負荷が高いことは明らかです。 一般的なアパレルCADにはサイズ毎のパターンデータを生成(グレーディング)する機能が備わっているとはいえ、MSアイテムで想定しているような大小様々なサイズにそのまま機能を適用できる訳ではありません。現状、細かなパターン調整工数が大量に発生しており、事業のスケールを妨げる一因になっています。 このような背景からMSアイテムのような多サイズ展開に適したパターンデータの生成ツールを開発するプロジェクトが発足しました。 コミュニケーション上の課題 大枠としてアパレル専門職であるグレーディングチームがパターンデータの生成に必要な業務要件を数式や図で定義し、それをエンジニア側で実装するという流れで開発を進めていました。 パターン生成のためのルールは例えば以下のように数式や関数として表現されており、ルールが増えていくにつれて変更に伴うタイポ、計算ミスがどうしても防ぎきれなくなっていきました。 またルールによっては表ではなく図で管理したいものも多くありました。しかし、数式とこれらの図の整合性をとることもルールが増えるにつれ困難になっていきました。 このようなルール管理上のタイポや計算ミス、ドキュメント間の不整合から要件の確認工数がどんどん増えてしまい、開発のボトルネックになってしまいました。 そこで、ここまでの進行の振り返りを実施し、コミュニケーション上の課題を明文化しました。結果として以下のような課題がメンバー全員の共通認識となりました。 大量の計算式が業務要件として定義されているため、単純なタイポ、計算ミスが防ぎきれない 業務要件のレビューの負荷が特定のメンバーに集中してしまっている 内容の重複があり、1箇所の変更が影響範囲に網羅的に反映されず、ドキュメント間の不整合が発生している 変更されたルールのステータス(検討中/検討済み)が曖昧である 当初想定しきれなかったルール定義上の課題に対処するため、ルールの変更が想定以上に頻繁に発生している 業務要件のMarkdown/PlantUML化 上記の課題を仕組みで解消するために、ツールの運用、特にそれまで業務要件の定義で使っていたGoogleスライドやスプレッドシートの利用を見直しました。 これらのツールは確かにアイデアの可視化や共有には便利ですが、今回のように数式や関数で定義されたルールが頻繁に更新されるケースでは、より組織的な変更管理フローとそれに適したツールが必要と判断しました。挙がっていた課題の解決に必要な機能(タイポの防止/相互レビュー/頻繁な更新に耐え得る変更履歴と変更ステータス管理)を洗い出していくとソースコード管理に求められるものと同等でした。 したがってエンジニア向けのツールを要件定義にも導入するのが良さそうだという見通しの元、ツールを選定しました。利用するメンバーが非エンジニアであることもふまえて特に以下の点を意識しました。 学習・導入・運用コストが小さいもの GUIで操作できるもの ツールの使い方に関してWeb検索ですぐHitするもの ルールの記述はMarkdownをベースとし、図示した方が分かりやすいものはPlantUMLを用いて作図することで全てのルールをテキスト化しました。導入するツールとルールの定義(または変更)のフローを以下のように定め、運用を始めました。 運用の初期対応 エンジニアにとってはどれも馴染み深いツールですが、他のチームのメンバーにとっては初めて使うものばかり。円滑に運用を開始するためにハンズオンを実施しました。 ハンズオンでの情報過多による運用初期の混乱を抑えるため、必要最小限のトピックに絞って説明するにとどめました。約3時間で以下のトピックについて説明しました。 Visual Studio CodeでのMarkdown編集、拡張機能の追加 よく使う機能 Open folder(フォルダごと開く) Search(フォルダ内テキスト検索) 正規表現を使った検索 例えばアルファベット大文字4桁+数字4桁の変数を全て検索したい場合:[A-Z]{4}[0-9]{4} Source Control Diff(差分) Discard changes(変更を破棄) Command list(F1キーで開く) File: Compare Active File With(2つのファイルを比較) Markdown: Open preview(Markdownをプレビュー) PlantUML: Preview Current Diagram(PlantUMLをプレビュー) Preferences: Open User Setting(ユーザー設定を変更) Git: Stash(変更に名前をつけて一時退避) Git: Pop Stash(退避した変更を戻す) キーボードショートカット マルチカーソルと選択 Visual Studio Codeに拡張機能を追加 おすすめ拡張機能 Markdown all in one Markdownが見やすくなる。自動フォーマット機能あり。 Highlight settings.jsonに以下のテキストを追加すると全角のスペース、かっこ、英数字をハイライト表示できるのでエラー防止に。 " highlight.regexes ": { " ( ) ": [{ " backgroundColor ": " lightgray " }] , " ([0-9A-Z()]) ": [{ " color ": " red ", " backgroundColor ": " yellow " }] } GitHubの仕組みを説明 バージョン管理とは リモート/ローカルリポジトリ 変更履歴の統合 ブランチ GitHub基本操作のハンズオン(テスト用のリポジトリを作ってみんなでアクセス!) 基本のPull, Commit, Push コンフリクトの発生と解消 独自ブランチでの作業 ブランチのMerge 変更履歴の確認 プロジェクトでの運用フロー説明 運用フローを明文化し、各レビュアーの観点も明記して記載内容やレビュアーの重複がなくなるよう運用を設計しました。 もちろん上記の他にも運用していく中で様々な状況への対応が必要ですし、より良い運用のために知っておくべき操作も数多くあります。 しかし、それらは運用しながら適宜コメントを入れることで徐々に理解を深めてもらうようにしました。分からなくてもまずは使ってもらう、試してもらうというモチベーションで使ってもらいました。 導入したツールを使ったやりとりはメンバー全員が見えるオープンなものになるため、エンジニアメンバーの使い方を見ながら他の非エンジニアメンバーも使い方を学んでいけることを期待しました。 自作のLinter導入、継続的メンテナンス 上記の運用に加え、わざわざ人間がチェックする必要のない単純なルールの記載ミスを検出するLinterをPythonで自作しました。このLinterはMarkdown Table及びPlantUMLの構造解析と正規表現マッチングによるエラー検出を組み合わせた簡易的なものです。 例えば以下のコードでMarkdownファイル内のTableから特定の一列を配列として取得します。 # Markdownファイルに含まれるTableのデータを配列に格納 def get_table (path): with open (path) as f: lines = f.readlines() table = [] for line in lines: row = re.split( ' \\ |' , line) cells = [] for cell in row: cell = cell.strip() cell = cell.replace( ' \\ ' , '' ) cells.append(cell) table.append(cells) return table # Tableのデータ配列から任意の一列を取得 def get_column_contents (table, header): index = table[ 0 ].index(header) contents = [] for row in table: contents.append(row[index]) del contents[: 2 ] # table headerを除外 return contents # テスト実行 def test (self): FILEPATH = './filename.md' COLUMN_NAME = 'Target Column Name' table = get_table(FILEPATH) column_contents = get_column_contents(table, COLUMN_NAME) # 取得した列データに対してテストを実行 # . # . Markdown Parserは既に数多くのものが開発され公開されていますが、今回のMarkdownの用途はTableに特化していたため既存のParserを使うのはオーバースペックと判断しました。 以下のコードは数式から変数のリストを抽出するものです。業務要件として定義されている数式や変数には一定のルールがあるため、正規表現で十分抽出できると判断しました。 # 数式から変数のリストを抽出する def get_var_list_in_formula (formula): result = [] if formula != '' : var_list_row = re.split( r'\(|\)|\+|\-|\*|\/|\=|\,' , formula) for var in var_list_row: if var != '' and re.search( r'[0-9]{0,1}[a-zA-Z]+' , var): var = var.strip( ' ' ) if re.match( r'[0-9]{1}' , var): var = var[ 1 :] var = remove_landmark_prefix(var) var = remove_landmark_suffix(var) result.append(var) return result # 接頭語付き変数から接頭語を除外する def remove_landmark_prefix (s): if s.startswith(( 'm' , 'g' , 'r' , 'd' )): s = s[ 1 :] return s # 接尾語付き変数から接尾語を除外する def remove_landmark_suffix (s): if s.endswith( 'c' ): s = s[:- 1 ] return s このLinterで以下のようなケースを検出できるようになりました。 未定義の変数の使用 関数内で引数の型、引数の数が想定外 未使用の変数の検出 タイポにありがちな不正な書式 本格的な構文解析ツールではないのでルールの拡充に伴い継続的にメンテナンスが必要なものの、目視確認に比べてメンテナンスコストが大きく削減され、施策としては有効な結果でした。何より大量の数式に含まれ得るタイポの目視確認が不要となり、精神衛生上かなり快適になりました。 Linterはローカルで実行できる他、GitHubにルールをPushするとCircleCIにより実行されるよう設定しチェック忘れを防止しています。 結果 これらの施策により、エンジニア側にインプットされる業務要件が精度の高いものになり、コミュニケーション上の課題を解消できました。 自作したLinterで単純なタイポ、計算ミスを検知 レビュアーの役割を分散し明文化 Markdown化に伴い項目の重複が最小限になるようルール定義のフォーマットを更新 GitHub上での適切なブランチ運用によりルールのステータス(検討中/検討済み)を明確化 頻繁なルール変更に耐え得る変更フローを定義 運用開始からこれまで、パターン生成ルールの追加/更新に関する311件のCommitのうち、29件でLinterによるエラーが検出され、不正な要件がmainブランチに入るのを食い止められました。Linter単体でも9.3%の精度向上に寄与しています。 加えてLinterでチェックできないポイントはGitHub上で相互レビューを通して議論され、正しい要件がmainブランチへ入るようになりました。 またブランチ管理によりアイデアベースの要件整理や複数のトピックを混ぜることなく並行して議論できるようになりました。チームで共有し、頻繁に更新する類のドキュメントは多少作成コストや学習コストをかけてでもMarkdownで管理していくことの良さも実感しました。 私個人だけでなく、アパレル専門職のメンバーからもコミュニケーションが明確になったと好評をいただきました。 おわりに 当初はエンジニア向けのツールを他のチームに押し付けたくないという思いもありましたが、コミュニケーション上の課題を明文化し、共通認識が持てたことで自信を持って提案できました。またこの共通認識により周囲のメンバーも前向きに取り組んでくれたものと考えています。 今回ご紹介した運用はまだ始めたばかりで、ブラッシュアップする余地はまだまだあるので、今後も継続的に改善していきます。 ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
はじめに こんにちは。ECプラットフォーム部のAPI基盤チームに所属している籏野 @gold_kou と申します。普段は、GoでAPI GatewayやID基盤(認証マイクロサービス)の開発をしています。 先日、 【ZOZOTOWNマイクロサービス化】API Gatewayを自社開発したノウハウ大公開! を公開したところ、多くの方からご好評いただきました。ありがとうございます。まだ読まれていない方はぜひご覧ください。 techblog.zozo.com 今回はその記事の続きです。API Gatewayは単にリバースプロキシの役割を担うだけでなく、ZOZOTOWN全体の可用性を高める仕組みを用意しています。本記事では、それらの中でカナリアリリース機能・リトライ機能・タイムアウト機能に関して実装レベルの紹介をします。 マイクロサービスに興味ある方や、API Gatewayを自社開発する方の参考になれば幸いです。 なお、本記事における可用性の定義は こちら を参考にしており、 成功したリクエスト数 /(成功したリクエスト数 + 失敗したリクエスト数) で計算できるものとします。 はじめに カナリアリリース機能 前回記事のおさらい ターゲットとターゲットグループ ルーティング 加重ルーティング スケジューラの基本 重み付きランダムサンプリングでなく重み付きラウンドロビンを採用 SchedulerインタフェースとFetchメソッド 初期化 どちらのスケジューラになるか 重みのバリデーション 一般的なラウンドロビンのスケジューラ 構造体 Fetchメソッド 重み付きラウンドロビンのスケジューラ 構造体 重みの約分 Fetchメソッド Fetchメソッドの呼び出し ターゲットのスケジューリング ターゲットグループのスケジューリング リトライ機能 前回記事のおさらい ターゲットグループを跨いだリトライ 設定方法 リバースプロキシとリクエスト情報の準備 マイクロサービスへのリクエスト タイムアウト機能 前回記事のおさらい ターゲットとターゲットグループのタイムアウト 設定 実装 機能追加の展望 We are hiring カナリアリリース機能 ここでは前回の記事では触れなかったカナリアリリース機能の実装面について主に紹介します。 カナリアリリースとは、一部のリクエストのみ新系サービスにアクセスさせて、新系サービスに問題がなければ段階的に新系サービスへの比重を高めるデプロイ方法です。 例えば、まずは新系と旧系を1:9の比重でリクエスト分散します。仮にこの段階で、新系の変更部分が起因で問題が発生した場合は、被害は一部(ここでは1割のリクエスト)で済みます。もし新系に問題なければ比重を徐々にあげていき、最終的には新系と旧系を10:0にします。旧系はこの時点で削除します。 新系サービスにバグがあっても新系への加重率を低くしていれば失敗リクエスト数は減るため、可用性の低下を抑えられます。インフラコストは増えるものの、エンドユーザへの影響を最小限に抑えつつ、エンジニアのリリース時の心理的なハードルを下げられます。 前回記事のおさらい 前提知識として必要なものを簡単に説明します。 ターゲットとターゲットグループ API Gatewayにはターゲットとターゲットグループという概念があります。ターゲットは転送先の接続情報(ホストとポート)です。ターゲットグループは、転送先であるターゲットをまとめた単位です。 ターゲットとターゲットグループの設定には、 target_groups.yml という名前のYAMLファイルを用意します。以下は設定の具体例です。TargetGroupAというターゲットグループの中に、2つのターゲットを設定しています。 TargetGroupA : targets : - host : target1.example.com port : 8080 - host : target2.example.com port : 8081 ルーティング ルーティングの設定には、 routes.yml という名前のYAMLファイルを用意します。 以下は設定の具体例です。ルーティングする転送元と転送先の情報を定義します。HTTPリクエストのパスが正規表現で ^/sample/(.+)$ に一致した場合、転送先のパスをGoの regexp.ReplaceAllString を使って、 /$1 に置き換えます。正規表現マッチした部分がURLのリライトの対象となるため、例えば /sample/hoge というパスでリクエストがきた場合は、 /hoge に置き換えられます。 - from : path : ^/sample/(.+)$ to : destinations : - target_group : TargetGroupA path : /$1 加重ルーティング 加重ルーティングの設定には、 target_groups.yml および routes.yml の重みを指定します。 target_groups.yml で指定する重みはターゲットに対する重みで、 routes.yml で指定する重みはターゲットグループに対する重みです。ターゲットとターゲットグループで2段階の重み付けができます。転送先の比重をコントロールすることで、加重ルーティングおよびカナリアリリースを実現できます。 下記は target_groups.yml でTargetGroupA内のtarget1.example.comとtarget2.example.comに4:1の比重で振り分ける例です。 TargetGroupA : targets : - host : target1.example.com port : 8080 weight : 4 - host : target2.example.com port : 8081 weight : 1 下記は routes.yml でTargetGroupAとTargetGroupBに2:1の比重で振り分ける例です。 - from : path : ^/sample/(.+)$ to : destinations : - target_group : TargetGroupA path : /$1 weight : 2 - target_group : TargetGroupB path : /$1 weight : 1 スケジューラの基本 転送先を決定するスケジューラの基本について説明します。 重み付きランダムサンプリングでなく重み付きラウンドロビンを採用 ランダムサンプリングとは、複数の候補から無作為に対象を選択する方法です。一様ランダムサンプリングとも呼びます。重み付きランダムサンプリングとは、各対象が何かしらの選ばれやすさに関するパラメータを持っている場合のランダムサンプリングです。 スケジューラとして重み付きランダムサンプリングを採用する手段もありました。しかしながら、あるマイクロサービスの特殊な要件により、例えば重みの割合が9:1の場合では10回に1回は確実に片方へアクセスすることを保証する必要があったため、重み付きラウンドロビンを採用しています。 SchedulerインタフェースとFetchメソッド ターゲットおよびターゲットグループのどちらも同一の Scheduler インタフェースで処理が共通化されています。 Scheduler インタフェースは Fetch メソッドを持ちます。実装するラウンドロビンの種類によって Fetch メソッドの動作は異なりますが、いずれの種類においてもターゲットを決めるためのインデックスをint型の値で返します。 type Scheduler interface { Fetch() int } 初期化 NewScheduler 関数でスケジューラの初期化をします。一般的なラウンドロビンスケジューラ roundRobinScheduler あるいは重み付きラウンドロビンスケジューラ weightedRoundRobinScheduler を返します。どちらのスケジューラも Fetch メソッドを実装しているため Scheduler インタフェースを満たしています。 func NewScheduler(weights [] int ) (Scheduler, error ) { e := validateWeights(weights) if e != nil { return nil , e } one := weights[ 0 ] for _, w := range weights[ 1 :] { if one != w { return newWeightedRoundRobinScheduler(weights), nil } } return newRoundRobinScheduler( len (weights)), nil } どちらのスケジューラになるか どちらのスケジューラになるかは、設定ファイル( target_groups.yml あるいは routes.yml )の weight の設定次第です。均等に weight の値が設定されているか weight が設定されていなければ一般的なラウンドロビンです。それ以外は重み付きラウンドロビンです。 下記は target_groups.yml の一般的なラウンドロビンの例です。 weight が設定されていません。 TargetGroupA : targets : - host : target1.example.com port : 8080 - host : target2.example.com port : 8081 下記は target_groups.yml の重み付きラウンドロビンの例です。異なる weight の値が設定されています。 TargetGroupA : targets : - host : target1.example.com port : 8080 weight : 2 - host : target2.example.com port : 8081 weight : 1 重みのバリデーション 重みに負の値が設定されていないか、一部の対象にしか重みが設定されていないかをバリデーションします。 func validateWeights(weights [] int ) error { nonweightedCount := 0 for _, weight := range weights { if weight < 0 { return errors.New( "invalid weight" ) } if weight == 0 { nonweightedCount++ } } if nonweightedCount != 0 && nonweightedCount != len (weights) { return errors.New( "mixed weighted and nonweighted targets" ) } return nil } 下記は target_groups.yml で weight に負の値が設定されているNG例です。 TargetGroupA : targets : - host : target1.example.com port : 8080 weight : -10 - host : target2.example.com port : 8081 weight : -1 下記は routes.yml で weight プロパティが設定されているものと、されていないものが混在しているNG例です。 - from : path : ^/sample/(.+)$ to : destinations : - target_group : TargetGroupA path : /$1 weight : 2 - target_group : TargetGroupB path : /$1 一般的なラウンドロビンのスケジューラ 一般的なラウンドロビンのスケジューラ実装について説明します。 構造体 roundRobinScheduler 構造体は以下の変数を持ちます。 mutex 複数のリクエストを排他制御する current 現在のインデックス 初期値は0(つまりリストの先頭) count ラウンドロビン対象の総数 newRoundRobinScheduler 関数の引数で渡される type roundRobinScheduler struct { mutex *sync.Mutex current int count int } newRoundRobinScheduler 関数は NewScheduler 関数から呼び出されます。初期化して roundRobinScheduler のポインタ型の変数を返します。 func newRoundRobinScheduler(count int ) *roundRobinScheduler { return &roundRobinScheduler{mutex: &sync.Mutex{}, current: 0 , count: count} } Fetchメソッド roundRobinScheduler 構造体の Fetch メソッドの処理内容は以下の通りです。 排他制御ロックをかける 次の呼び出しに備えてインデックス current をインクリメントしておく リスト上に記載された次の対象を返すようにする もしリスト上で最後の場合は、最初のターゲットを返す 現在のインデックスを返す func (s *roundRobinScheduler) Fetch() int { s.mutex.Lock() defer s.mutex.Unlock() i := s.current s.current = (i + 1 ) % s.count return i } 例えば、下記の target_groups.yml があった場合に3回 Fetch メソッドを実行するときの挙動を見てみます。 TargetGroupA : targets : - host : target1.example.com port : 8080 - host : target2.example.com port : 8081 まず、 roundRobinScheduler 構造体の以下のフィールドは値が決定します。 count: 2 各実行回数とその時の最終的な値は以下の通りです。次回は前回の変数の値を引き継ぎます。 1回目 Fetch()の戻り値: 0(target1.example.comが選択される) current: 1 2回目 Fetch()の戻り値: 1(target2.example.comが選択される) current: 0 3回目 Fetch()の戻り値: 0(target1.example.comが選択される) current: 1 重み付きラウンドロビンのスケジューラ 重み付きラウンドロビンのスケジューラ実装について説明します。 構造体 weightedRoundRobinScheduler 構造体は以下の変数を持ちます。 mutex 複数のリクエストを排他制御する weights 対象全ての重み newWeightedRoundRobinScheduler 関数の引数で渡される maxWeight weights の中で最大の重み newWeightedRoundRobinScheduler 関数で決定される currentIndex 現在のインデックス Fetch メソッドで返される値 初期値は -1 currentWeight インデックスを返す際の基準となる重み currentWeight よりも重い weight を持つ currentIndex を Fetch メソッドで返す 初期値は0 type weightedRoundRobinScheduler struct { mutex *sync.Mutex weights [] int maxWeight int currentIndex int currentWeight int } newWeightedRoundRobinScheduler 関数は NewScheduler 関数から呼び出されます。 normalizeWeights 関数を実行して weightedRoundRobinScheduler のポインタ型の変数を返します。処理中に、引数で渡された重み weights を normalizeWeights 関数で約分します。 func newWeightedRoundRobinScheduler(weights [] int ) *weightedRoundRobinScheduler { normalizedWeights := normalizeWeights(weights) max := 0 for _, w := range normalizedWeights { if w > max { max = w } } return &weightedRoundRobinScheduler{weights: normalizedWeights, maxWeight: max, currentIndex: - 1 , currentWeight: 0 , mutex: &sync.Mutex{}} } 重みの約分 normalizeWeights 関数は gcd 関数により求めた最大公約数で weights の要素を全て約分した結果を返します。約分する理由は、 Fetch メソッド内で無駄なforループを無くすためです。 func normalizeWeights(weights [] int ) [] int { g := weights[ 0 ] for _, w := range weights[ 1 :] { g = gcd(g, w) } normalizedWeights := [] int {} for _, w := range weights { normalizedWeights = append (normalizedWeights, w/g) } return normalizedWeights } gcd 関数は標準パッケージの math/bigのGCD を利用して ユークリッドの互除法 により、最大公約数(greatest common divisor)を返します。 func gcd(m, n int ) int { x := new (big.Int) y := new (big.Int) z := new (big.Int) a := new (big.Int).SetUint64( uint64 (m)) b := new (big.Int).SetUint64( uint64 (n)) result := z.GCD(x, y, a, b) return int (result.Int64()) } Fetchメソッド はじめに、イメージを掴んでいただくために Fetch メソッドによる走査処理の概要から説明します。 例えば、 target1に3、target2に5、target3に1 といった重み付けがされたケースを考えます。この場合、以下の割り当て順にはなりません。 target1 →target1 →target1 →target2 →target2 →target2 →target2 →target3 下のような2次元配列のようにして上から順に、左から走査する形で割り当てます。 weight 5: target2 weight 4: target2 weight 3: target1 target2 weight 2: target1 target2 weight 1: target1 target2 target3 つまり、割り当て順は以下になります。 target2 →target2 →target1 →target2 →target1 →target2 →target1 →target2 →target3 それでは、実装を見ていきます。 weightedRoundRobinScheduler 構造体の Fetch メソッドの処理内容は以下の通りです。 排他制御ロックをかける currentIndex をインクリメントし、リストの最後の場合は先頭に戻る currentIndex がリストの先頭の場合は、 currentWeight をデクリメントする デクリメントした currentWeight が負の値であれば currentWeight に maxWeight を代入する currentIndex における weights の値が currentWeight 以上であればその currentIndex を返し、未満であれば2-4のステップを繰り返す func (s *weightedRoundRobinScheduler) Fetch() int { s.mutex.Lock() defer s.mutex.Unlock() for { s.currentIndex = (s.currentIndex + 1 ) % len (s.weights) if s.currentIndex == 0 { s.currentWeight = s.currentWeight - 1 if s.currentWeight <= 0 { s.currentWeight = s.maxWeight } } if s.weights[s.currentIndex] >= s.currentWeight { return s.currentIndex } } } 例えば、下記の target_groups.yml があった場合に3回 Fetch メソッドを実行するときの挙動を見てみます。 TargetGroupA : targets : - host : target1.example.com port : 8080 weight : 1 - host : target2.example.com port : 8081 weight : 2 まず、 weightedRoundRobinScheduler 構造体の以下のフィールドは値が決定します。 maxWeight: 2 weights: [1, 2] 各実行回数とその時の最終的な値は以下の通りです。次回は前回の変数の値を引き継ぎます。 1回目 Fetch()の戻り値: 1(target2.example.comが選択される) currentIndex: 1 currentWeight: 2 2回目 Fetch()の戻り値: 0(target1.example.comが選択される) currentIndex: 0 currentWeight: 1 3回目 Fetch()の戻り値: 1(target2.example.comが選択される) currentIndex: 1 currentWeight: 1 Fetchメソッドの呼び出し 上記で説明した Fetch メソッドの呼び出し側について説明します。ターゲットとターゲットグループのいずれのスケジューリングにも使用できます。 ターゲットのスケジューリング 下記のように target_groups.yml の targets 配下には複数のターゲットを指定可能です。 TargetGroupA : targets : - host : target1.example.com port : 8080 weight : 1 - host : target2.example.com port : 8081 weight : 2 TargetGroup 構造体は Scheduler インタフェース型の変数 scheduler をフィールドとして持っています。したがって、 TargetGroup 構造体のポインタ型をレシーバに持つ ScheduledTargets メソッド内で Fetch メソッドを呼び出せます。 ScheduledTargets メソッドは、複数の中から1つのターゲットを決定するために Fetch メソッドを呼び出します。 Fetch メソッドにより取得したインデックスで最初のターゲットを決定します。 type Target struct { Host string Port int Timeout timeout } type TargetGroup struct { // ... scheduler scheduler.Scheduler targets []*Target } func (targetGroup *TargetGroup) ScheduledTargets(length int ) []Target { targetIndex := targetGroup.scheduler.Fetch() roundRobinTargets := []Target{} target := targetGroup.targets[targetIndex] // ... return roundRobinTargets } ターゲットグループのスケジューリング 下記のように routes.yml の destinations 配下には複数のターゲットグループを指定可能です。 - from : path : ^/sample/(.+)$ to : destinations : - target_group : TargetGroupA path : /$1 - target_group : TargetGroupB path : /$1 routeTo 構造体は Scheduler インタフェース型の変数 scheduler をフィールドとして持っています。したがって、 routeTo 構造体のポインタ型をレシーバに持つ scheduledDestination メソッド内で Fetch メソッドを呼び出せます。複数の中から1つのターゲットグループを決定するために Fetch メソッドを使用します。 Fetch メソッドにより取得したインデックスでターゲットグループを選定します。 type routeTo struct { destinations []destination scheduler scheduler.Scheduler } type destination struct { targetGroup *targetgroup.TargetGroup path string } func (routeTo *routeTo) scheduledDestination() destination { destinationIndex := routeTo.scheduler.Fetch() return routeTo.destinations[destinationIndex] } リトライ機能 ここでは前回の記事では触れなかったリトライ機能の一部仕様を紹介します。 前回記事のおさらい 前提知識として必要なものを簡単に説明します。 リトライ機能は、リクエスト失敗時にAPI Gatewayとマイクロサービス間でリトライする機能です。どのようなシステムであっても、なんらかの原因でリクエストが失敗する可能性はあります。 例えば、転送先マイクロサービスの一時的なエラー、通信問題、タイムアウトなどです。その失敗をAPIクライアントへそのまま返さずに、API Gatewayとマイクロサービス間でリトライします。リトライによりリクエストが成功すれば、エンドユーザへエラーを返さずにすむため成功リクエスト数が増えることになり、可用性を高められます。 以下のように、リトライ回数やリトライ条件、リトライ先ターゲット、リトライ前のスリープを target_groups.yml から設定可能です。 TargetGroupAB : targets : - host : target-a-1.example.com port : 8080 retry_to : target-b-2.example.com - host : target-a-2.example.com port : 8080 retry_to : target-b-1.example.com - host : target-b-1.example.com port : 8080 retry_to : target-a-2.example.com - host : target-b-2.example.com port : 8080 retry_to : target-a-1.example.com max_try_count : 3 retry_cases : [ "server_error" , "timeout" ] retry_non_idempotent : true retry_base_interval : 50 retry_max_interval : 500 ターゲットグループを跨いだリトライ デフォルトでは、リトライ先のターゲットは同一ターゲットグループに属するもので限定されるため、ターゲットグループを跨ぎません。しかしながら、ターゲットグループを跨いだリトライでメリットが生まれるケースもあります。 例えば、TargetGroupAを新系、TargetGroupBを旧系のターゲットグループとします。TargetGroupAのTarget1で変更部分のバグによりリクエストが失敗した場合に、同じターゲットグループのTarget2へリトライしても同じ失敗になります。しかしながら、そのバグを含まないTargetGroupBのTarget3へリトライすればエンドユーザへの影響を最小限に抑えられます。エンドユーザへの若干のレスポンス速度の低下は発生しますが、エラーが返らずに済みます。また、マイクロサービスがターゲットグループ間で異なる場合は、下図のようにパスも異なる可能性があります。 設定方法 target_groups.yml の retry_to_target_group_id プロパティにターゲットグループIDを指定します。リトライ時は指定したターゲットグループのターゲットへのリトライになります。 下記は TargetGroupA の retry_to_target_group_id で TargetGroupB を指定している例です。 TargetGroupA : targets : - host : target1.example.com port : 8080 retry_to_target_group_id : TargetGroupB TargetGroupB : targets : - host : target2.example.com port : 8081 リバースプロキシとリクエスト情報の準備 HTTPリクエストをマイクロサービスへ転送する上で、リトライ情報を含めたリクエスト準備が必要です。ここでは、リクエスト情報を準備する上でどのように転送先のターゲット情報やリトライ情報を作成しているかの実装面を紹介します。 ServeHTTP メソッドは Route メソッドと transferRequest メソッドを実行します。 http.Handler のインタフェースのメソッドでもあります。 func (reverseProxy ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { // ... func () { // ... fetchedTargetGroup, routedPathMap, e := reverseProxy.router.Route(client, ip, r) // ... response, body, tryURLs, e = reverseProxy.transferRequest(r, fetchedTargetGroup, routedPathMap, traceID, client) // ... } } Route メソッドは fetchedTargetGroup と routedPathMap を返します。 fetchedTargetGroup は、内部の Fetch メソッドにより取得した最初の転送先ターゲットグループです。 routedPathMap はマッチしたルーティングに含まれる全てのターゲットグループとAPIパスの組み合わせを持つマップです。 func (router Router) Route(c client.Client, ip net.IP, req *http.Request) (fetchedTargetGroup *targetgroup.TargetGroup, routedPathMap map [*targetgroup.TargetGroup] string , e error ) { routedPathMap = map [*targetgroup.TargetGroup] string {} for _, route := range router.routes { // ... fetchedTargetGroup = route.to.scheduledDestination().targetGroup for _, d := range route.to.destinations { routedPathMap[d.targetGroup] = route.from.path.ReplaceAllString(req.URL.Path, d.path) } return } // ... return } func (routeTo *routeTo) scheduledDestination() destination { destinationIndex := routeTo.scheduler.Fetch() return routeTo.destinations[destinationIndex] } transferRequest メソッドはHTTPリクエストの転送処理をします。 Route メソッドの戻り値の fetchedTargetGroup と routedPathMap を引数に渡します。 func (reverseProxy ReverseProxy) transferRequest(r *http.Request, fetchedTargetGroup *targetgroup.TargetGroup, routedPathMap map [*targetgroup.TargetGroup] string , traceID string , client client.Client) (response *http.Response, body [] byte , tryURLs [] string , e error ) { // ... } transferRequest メソッド内では target_groups.yml に retry_to_target_group_id が指定されているかどうかで異なるリクエスト情報を作成します。 最大の試行回数 maxTryCount だけ、 targets 変数に以下のケースに基づいたターゲット情報を格納します。 retry_to_target_group_id が指定されていない場合 fetchedTargetGroup の ScheduledTargets の戻り値 retry_to_target_group_id が指定されている場合 最初のターゲットは fetchedTargetGroup の ScheduledTargets の戻り値の1つ目の要素 それ以降のターゲットはリトライ先のターゲットグループの ScheduledTargets の戻り値(要素数は1つ減らす) maxTryCount := fetchedTargetGroup.MaxTryCount() if !fetchedTargetGroup.RetryNonIdempotent() && (r.Method == http.MethodPost || r.Method == http.MethodPatch) { maxTryCount = 1 } var targets []targetgroup.Target if fetchedTargetGroup.RetryToTargetGroup() == nil { targets = fetchedTargetGroup.ScheduledTargets(maxTryCount) } else { firstTarget := fetchedTargetGroup.ScheduledTargets( 1 )[ 0 ] targets = append (targets, firstTarget) scheduledTargets := fetchedTargetGroup.RetryToTargetGroup().ScheduledTargets(maxTryCount - 1 ) targets = append (targets, scheduledTargets...) } ScheduledTargets メソッドのリトライ対象の決定に関する処理を補足します。 Fetch メソッドで先頭のターゲットIDを決定した後に、for文内では決定したターゲット情報をkeyに retryTargetMap フィールドからリトライ先情報を取得します。 retryTargetMap は、keyがターゲットIDでvalueにそのターゲットIDへのリクエストが失敗した場合の次の転送先のターゲットIDを持ちます。リトライ先のターゲットはラウンドロビンでなく target_groups.yml の retry_to に設定されたターゲットあるいは次のインデックスのターゲットを使用します。このようにして、引数のリトライ上限回数 length の数だけfor文でターゲット情報の集合 roundRobinTargets を生成して返します。 type TargetGroup struct { // ... retryTargetMap map [*Target]*Target } func (targetGroup *TargetGroup) ScheduledTargets(length int ) []Target { targetIndex := targetGroup.scheduler.Fetch() roundRobinTargets := []Target{} target := targetGroup.targets[targetIndex] for len (roundRobinTargets) < length { roundRobinTargets = append (roundRobinTargets, *target) target = targetGroup.retryTargetMap[target] } return roundRobinTargets } 作成した targets の数、つまり最大の試行回数だけ以下の情報を作成します。 url.Path 変数に以下のケースに基づいたパス情報を格納します。 retry_to_target_group_id が指定されていない場合 fetchedTargetGroup をkeyに routedPathMap から取得したもの retry_to_target_group_id が指定されている場合 リトライ先のターゲットグループをkeyに routedPathMap から取得したもの retryInfo 変数に以下のケースに基づいたリトライ情報を格納します。 retry_to_target_group_id が指定されていない場合 fetchedTargetGroup のリトライ情報 retry_to_target_group_id が指定されている場合 リトライ先のターゲットグループのリトライ情報 targetURLs := [] string {} // ... type retryInfo struct { cases []targetgroup.RetryCase baseInterval int maxInterval int } retryInfos := []retryInfo{} for i, target := range targets { url := *r.URL url.Scheme = "http" url.Host = fmt.Sprintf( "%v:%v" , target.Host, target.Port) var r retryInfo retryToTargetGroup := fetchedTargetGroup.RetryToTargetGroup() if i == 0 || retryToTargetGroup == nil { url.Path = routedPathMap[fetchedTargetGroup] r = retryInfo{ cases: fetchedTargetGroup.RetryCases(), baseInterval: fetchedTargetGroup.RetryBaseInterval(), maxInterval: fetchedTargetGroup.RetryMaxInterval(), } } else { url.Path = routedPathMap[retryToTargetGroup] r = retryInfo{ cases: retryToTargetGroup.RetryCases(), baseInterval: retryToTargetGroup.RetryBaseInterval(), maxInterval: retryToTargetGroup.RetryMaxInterval(), } } targetURLs = append (targetURLs, url.String()) // ... retryInfos = append (retryInfos, r) } マイクロサービスへのリクエスト 上述の通り、リトライ情報含めたリクエストに必要な情報を準備しました。後続の処理では transferRequestToHTTP 関数でマイクロサービスにHTTPリクエストします。こちらの関数内処理の詳細は本リトライの話から逸れますので割愛します。 for i, targetURL := range targetURLs { response, body, e = transferRequestToHTTP(r, requestBody, targetURL, httpClients[i], traceID, client) // ... } タイムアウト機能 ここでは前回の記事では触れなかったタイムアウト機能の一部仕様を紹介します。 前回記事のおさらい 前提知識として必要なものを簡単に説明します。 タイムアウトとは、その名の通り、一定の期間が経過したリクエストを打ち切ることです。実行が長引いているリクエストをタイムアウトさせて、後ろに詰まっているリクエストを正常に処理することで全体としてリクエスト成功数が増え、可用性を高められます。 API GatewayにはリバースプロキシするHTTPリクエストのタイムアウト設定が可能です。 設定項目は以下の通りです。 connect_timeout 1リクエストあたりのTCPコネクション確立までの間のタイムアウト値(ミリ秒単位) read_timeout 1リクエストあたりのリクエスト開始からレスポンスボディを読み込み終わるまでの間のタイムアウト値(ミリ秒単位) ターゲットとターゲットグループのタイムアウト ターゲットとターゲットグループの両方にタイムアウト設定が可能です。 例えば、マイクロサービス化した新しいターゲットはレスポンスが速いのでそちらだけタイムアウトを小さくしたいといったケースです。該当のターゲットにタイムアウト設定しつつ他のターゲットにはターゲットグループのタイムアウト設定をデフォルトとして適用させることが可能です。 設定 以下の target_groups.yml の通り、両方で connect_timeout と read_timeout の設定が可能です。 TargetGroupA : targets : - host : target1.example.com port : 8080 connect_timeout : 10 read_timeout : 5000 connect_timeout : 50 read_timeout : 3000 実装 TargetGroupConfig は target_groups.yml に対応する構造体です。タイムアウト関連のフィールドも持ちます。 type TargetGroupConfig struct { Targets [] struct { // ... ConnectTimeout int `yaml:"connect_timeout"` ReadTimeout int `yaml:"read_timeout"` // ... } `yaml:"targets"` ConnectTimeout int `yaml:"connect_timeout"` ReadTimeout int `yaml:"read_timeout"` // ... } newTargetGroup 関数は TargetGroup 型の変数を返します。その処理過程でターゲットグループとターゲットのタイムアウト設定を読み込みます。ターゲットのタイムアウト設定に関しては、 ターゲットの設定>ターゲットグループの設定>ハードコーディングによるデフォルト設定 の順で優先付けされています。 const defaultConnectTimeout = 1000 const defaultReadTimeout = 10000 const defaultRetryBaseInterval = 50 // ... func newTargetGroup(targetGroupConfig TargetGroupConfig) (TargetGroup, error ) { targets := []*Target{} // ターゲットグループにタイムアウト設定があればそれを使う。なければハードコーディングのデフォルト値とする。 if targetGroupConfig.Timeout < 0 || targetGroupConfig.ReadTimeout < 0 || targetGroupConfig.ConnectTimeout < 0 { return TargetGroup{}, errors.New( "invalid timeout" ) } targetGroupConnectTimeout := defaultConnectTimeout if targetGroupConfig.ConnectTimeout != 0 { targetGroupConnectTimeout = targetGroupConfig.ConnectTimeout } targetGroupReadTimeout := defaultReadTimeout if targetGroupConfig.ReadTimeout != 0 { targetGroupReadTimeout = targetGroupConfig.ReadTimeout } else if targetGroupConfig.Timeout != 0 { targetGroupReadTimeout = targetGroupConfig.Timeout } // ... // ターゲットのタイムアウト設定があればそれを使う。なければターゲットグループの値とする。 for i, t := range targetGroupConfig.Targets { targetConnectTimeout := targetGroupConnectTimeout targetReadTimeout := targetGroupReadTimeout if t.ConnectTimeout != 0 { targetConnectTimeout = t.ConnectTimeout } if t.ReadTimeout != 0 { targetReadTimeout = t.ReadTimeout } target := Target{ // ... Timeout: timeout{ Connect: targetConnectTimeout, Read: targetReadTimeout, }, } // ... } // ... } Target 構造体のタイムアウト値は、 http.Client の Timeout に Connect の値、 net.Dialer の Timeout に Read の値を使用します。 http.Client{ Transport: &http.Transport{ DialContext: (&net.Dialer{ Timeout: time.Duration(connectTimeout) * time.Millisecond, // ... }).DialContext, // ... }, // ... Timeout: time.Duration(readTimeout) * time.Millisecond, } 機能追加の展望 今後、さらに可用性を高める以下の機能追加を考えています。 スロットリング クライアントタイプごとにレートリミットで制限するような機能を想定 一部のクライアントタイプによる大量リクエストでシステム全体が停止するのを避け、可用性を高める サーキットブレーカー ある閾値以上の失敗が続いたら、そのマイクロサービスにはリクエストを送らずにAPI Gatewayが503エラーを返すような機能を想定 カスケード障害を防ぎ、可用性を高める We are hiring ZOZOTOWNのマイクロサービス化はまだ始まったばかりです。今後は、API GatewayやID基盤の追加開発に加えて、新たなマイクロサービスの開発も目白押しです。そのためのエンジニアが足りていない状況です。 ご興味のある方は、以下のリンクからぜひご応募ください。お待ちしております。 hrmos.co
こんにちは。ECプラットフォーム部の廣瀬です。 弊社ではサービスの一部にSQL Serverを使用しています。今回は2020年度に発生していたSQL Serverに関連する障害について、調査から対策実施までの流れを紹介したいと思います。これまでも弊社テックブログにて、SQL Serverに関するトラブルシューティングをいくつか紹介してきました。 techblog.zozo.com techblog.zozo.com techblog.zozo.com これらの記事と今回の記事の最大の相違点としては、「最後まで明確な原因の特定はできなかった」という点です。できる限り詳細な調査を実施しましたが、最後まで原因の特定には至りませんでした。そのような状況下において、どのようなフローで調査を実施し、最終的に障害が発生しない状況を作ることができたか紹介します。 SQL Server以外のデータストアを運用していて障害調査をすることがある方にも読んでいただけると幸いです。 障害の概要 弊社のサービスは、様々な要因でトラフィックが増大します。例えば、セールイベントや福袋発売イベントなどです。2020年の中頃から、ある機能を担っていたDBにおいて秒間のバッチ実行数が一定の閾値を超えると、アプリケーション側でタイムアウトエラーが多発するようになりました。尚、 バッチ とはSQL Serverにおける1つ以上のクエリのかたまりを指します。 上の図では、タイムアウトエラーの多発開始と同時に、秒間のバッチ実行数が急激に下がりDBサーバーのクエリ処理性能が落ちていることが分かります。また、バッチの総実行時間が急激に増えていることから、1クエリあたりの実行時間が大幅に伸びていることも分かります。 この事象を詳細に調査していきました。調査に用いた情報は、以下の3点です。 パフォーマンスモニタのSQL Serverに関連するメトリクス 拡張イベントの「blocked_process_report」 DMV(動的管理VIEW)の各種データを1分毎にテーブルへ保存しておいたもの 弊社のSQL Server障害調査フロー 弊社では、SQL Serverに関する障害が発生した際、以下のフローチャートに沿って調査を進めています。 以降では、このフローに沿って実際に障害を調査した事例を紹介します。 調査内容 1. 主要なメトリクスを確認 パフォーマンスモニタで取得したメトリクスのうち、主要なものを確認しました。フローチャートの①に該当します。 CPU使用率はピーク時で100%に近い値となっていました。Batch Resp Statisticsを確認すると、CPU使用時間が20msecから100msecのバッチがCPU使用率上昇の主な要因となっていました。その他、目立った変化のあったグラフを以下に列挙します。 これらのグラフから、以下のことが読み取れます。 ブロッキングの発生が確認できた SQL Server観点でもエラー発生が確認できた 待ち事象としては非ページラッチが障害発生の初期にスパイクしていた バッチ実行数の増加に伴ってコンパイル数も増加していた ワークスペースメモリを獲得したクエリの数が増大していた ワーカースレッド獲得待ちが多く発生しており、ワーカースレッドが枯渇していた可能性が高い これらの情報を見ただけでは、「何らかの理由でクエリが滞留し、最終的にワーカースレッドが枯渇してタイムアウトエラー多発につながった」ということしか分かりません。 クエリが滞留した理由としては、以下のようなものが考えられます。 CPU使用率が高騰したことでSOS_SCHEDULER_YIELD待ちが多発 非ページラッチの競合の発生 アクセスが集中する領域に長時間ロックがかかったことでブロッキングが多発 このような可能性に留意しつつ、エラーが起き始めた変化点にフォーカスし、より短い時間軸でメトリクスを確認しました。「エラーが起き始めたタイミングと同タイミングで変化し始めたメトリクス」が障害に関連している可能性が高いと考えたからです。 青い線がCPU使用率ですが、Attentionが発生したタイミングではCPU使用率が低下しているためCPU使用率が障害発生の原因とは考えにくい状況でした。エラー発生前後の最初の変化としては、まずPage Latchの発生が確認でき、次にNon-Page Latch、ブロッキングの順番で発生が確認できました。この結果から、Page Latch、Non-Page Latch、ブロッキングの発生は、エラーが発生し始めた要因と関連性がありそうです。 このような可能性に留意しつつ、保存しておいた拡張イベントとDMVの情報を後追いしていきました。 2. 拡張イベントでブロッキング状態を確認 こちら の方法で、「blocked_process_report」イベントをクエリベースで確認しました。フローチャートの②に該当します。 この結果から、以下のことが分かりました。 最初にブロッキングイベントが発生したのは、障害発生から15秒ほど経過した後 最初のブロッカーはbackgroundプロセスであり、自身も「ACCESS_METHODS_HOBT_VIRTUAL_ROOT」という種類のラッチで待たされていた 「ACCESS_METHODS_HOBT_VIRTUAL_ROOT」は ドキュメント によると、「内部Bツリーのルートページの抽象化へのアクセスを同期するために使用されます」と説明してありました。ブロッキングイベントが発生したのは障害発生から15秒ほど経過してからでしたが、パフォーマンスモニタ上では障害発生前からわずかにブロッキングが発生していました。拡張イベントのブロッキングイベントは、「〇秒以上ブロッキングが続いたら発生」というように一定の閾値を超えたものだけが記録されるため、イベントが記録されるまでにタイムラグが発生していたようです。また、拡張イベントに記録された最初のブロッカーがどのような処理をしていたかは分からず、原因特定には至りませんでした。 3. DMVの各種データを使ってドリルダウン フローチャートの③に該当します。 CPUのボトルネッククエリ調査 こちら の方法でCPUボトルネックとなっているクエリが無いかを調査しました。Batch Resp Statisticsの確認結果から、特にCPU使用時間が20msecから100msecのバッチを重点的に確認しました。しかし、今回はCPU負荷の観点でチューニングできそうなクエリは見つかりませんでした。 ラッチ こちら のクエリを使って障害発生中の各種ラッチの待ち時間を確認しました。 ラッチによる総待機時間の大きい順に並べた結果、ACCESS_METHODS_ACCESSOR_CACHEが最も大きく、次いでBUFFER、ACCESS_METHODS_HOBT_VIRTUAL_ROOTという順番になりました。 ACCESS_METHODS_HOBT_VIRTUAL_ROOTについては、平均の待機時間が大きいようです。この待ちはページ分割時などに発生すると理解しており、特定のテーブルへのLast Page Insert多発が原因の可能性として挙げられます。その場合は、テーブルのキーの再設計などによって該当のラッチを減らせるかもしれないと考えました。 ACCESS_METHODS_ACCESSOR_CACHEについては、コンパイルの発生に伴って値が上昇する傾向にあるようです。したがってコンパイル回数の抑制によって該当のラッチ待ち時間を低減できる可能性があると考えました。 待ち事象 こちら のクエリを使って、障害発生中の待ち事象について確認しました。 THREADPOOLは二次的な待ちのため無視するとして、非ページラッチ、ページラッチ、ロックなどが待ち事象として多くを占めていました。 実施した対応策 時間をかけて調査しましたが、根本的な原因の特定には至りませんでした。したがって次善の策として、取得した情報を使って「障害発生中に観測できた事象の中から、無くしたり減らすことのできるものを探す」という観点で対応策を決めて実施していきました。 1. 特定のストアドプロシージャのプランガイドの固定化 別の日に同様の障害が発生した際、拡張イベントの「blocked_process_report」におけるwaitresourceごとのイベント件数を抽出してみました。その結果、特定のストアドプロシージャのコンパイル待ちが原因でブロッキングが発生していることが分かりました。 したがって、これら4つのストアドプロシージャのプランガイドを固定化することにしました。これは根本的な原因を特定したうえでの対応策ではありませんが、「もし同様の事象が発生しても、コンパイル起因でのブロッキングの発生を0にする」という効果が期待できます。 2. コンパイルとリコンパイル発生を抑制する施策 バッチ実行数の上昇に伴ってコンパイル数も上昇したことがパフォーマンスモニタのメトリクスから確認できていました。コンパイルやリコンパイルによって効率の悪い実行プランが採用されてしまい、CPU使用率が上昇した可能性も考えられます。したがって、コンパイルとリコンパイルの発生回数を抑制する対応を実施しました。コンパイルとリコンパイルの発生要因として代表的なものは、以下の2つです。 関連テーブルの統計情報が更新された 実行プランがキャッシュアウトされた 1.については、統計情報の更新が今回の環境におけるリコンパイルの理由として本当に正しいのかをまず検証しました。拡張イベントで「sql_statement_recompile」イベントを取得することで、リコンパイルの理由を確認できます。 拡張イベントの作成後、以下のようにリコンパイルイベントの発生が確認できました。 リコンパイルの理由は「recompile_cause」に記載されており、図の中では「Statistics changed」となっています。つまり、統計情報の更新がリコンパイルの理由だと分かります。 リコンパイルの各理由の内訳を確認するために「recompile_cause」でグループ化してみると、下図のようにリコンパイルの原因の大多数は統計情報の更新であることが確認できました。 したがって、障害発生時のリコンパイルについても統計情報の自動更新が起因となったものが大多数であるはずだと判断しました。次に、 こちらのクエリ を使って障害が発生した時間帯で「統計情報が1回以上更新されたテーブル」をリストアップしました。この情報をもとに、トラフィック増が見込まれる時間帯だけ、リストアップしたテーブル群の統計情報の自動更新を一時的に無効化するようにしました。統計情報の自動更新を無効化するには、以下のクエリを実行すればOKです。 UPDATE STATISTICS tablename WITH NORECOMPUTE これによって、統計情報の更新が起因となったリコンパイルの発生を抑止できます。しかし、自動更新を常に無効化しておくのは、最適な実行プランが生成されなくなる確率を上昇させる高リスクな行為です。したがって、トラフィックが落ち着いてきたタイミングで自動更新を再度有効化するようにしました。自動更新の有効化は、以下のクエリのように「WITH NORECOMPUTE」無しで統計情報を更新すればOKです。 UPDATE STATISTICS tablename 2.のプランキャッシュアウトについては、SQL Serverがメモリの利用状況をコントロールしているため、こちらで制御できません。ベストエフォートな対応として、メモリに載っているデータキャッシュのうち、容量が大きいインデックスを圧縮することでメモリの利用効率アップを試みました。尚、メモリに載っているサイズが大きいインデックスを抽出するには こちらのクエリ を使用しました。 結果 各種対策を実施したことで、障害が発生しなくなりました。対策実施前と後での各メトリクスの変化を紹介します。 同程度のバッチ実行数の増加でも、クエリの滞留が起きなくなったため、対策実施後はコネクションの上昇が顕著に抑えられるようになりました。エラーが発生することもなくなり、明らかにパフォーマンスが改善しました。 CPU使用率はピーク時100%近くだったのを、ピーク時60%ほどまで抑えられるようになりました。 コンパイル数とリコンパイル数も顕著に発生回数を抑えられるようになりました。リコンパイルについては、統計情報の自動更新を一時的に無効化したことが顕著に効いています。コンパイルの発生回数が抑えられた要因については、仮説として「データ圧縮によりデータキャッシュのサイズが減少し、プランキャッシュが安定したことでキャッシュアウトしづらくなった」と考えられます。 対策実施前はクエリの総実行時間が障害時に大幅に増加していたのに対し、対策実施後は安定しました。トラフィック増により秒間のバッチ実行数が増えても、同時実行性が低下することなく、安定して処理できていることが分かります。 ロック競合の発生状況も大幅に改善しており、対策実施後は秒間のバッチ実行数が最大に達したタイミングでも、ロック競合は確認されませんでした。 このように、各種メトリクスに大幅な改善傾向がみられ、結果としてトラフィック増大時でも障害が発生しない状況をつくることができました。 まとめ 本記事では、2020年度に発生していたSQL Serverに関連する障害について、調査から対策実施までの流れを紹介しました。まずは特異な変化が起きているメトリクスを確認し、原因の仮説を立てながらDMVや拡張イベントを使ってドリルダウンしていきました。また、障害発生の前後数秒間で起きた変化を細かく見ていく調査も実施しました。いくつか障害原因の仮説は立てられたものの、特定にまでは至りませんでした。 こうした根本的な原因が特定できない状況において、「障害発生中に観測できた事象の中から、無くしたり減らすことのできるものを探す」という観点で対応策を決めて実施しました。その結果、最終的に障害が発生しない状況を作ることができました。原因が特定できない障害調査における対応策の実施について、今回紹介した考え方が参考になれば幸いです。 最後に ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com