TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

938

こんにちは、データ基盤の開発、運用をしていた谷口( case-k )です。最近は配信基盤の開発と運用をしています。 ZOZOではオンプレやクラウドにあるデータをBigQueryへ連携し、分析やシステムで活用しています。BigQueryに連携されたテーブルは共通データ基盤として全社的に利用されています。 共通データ基盤は随分前に作られたこともあり、様々な負債を抱えていました。負債を解消しようにも利用者が約300人以上おり、影響範囲が大きく改善したくても改善できずにいました。 本記事では旧データ基盤の課題や新データ基盤の紹介に加え、どのようにリプレイスを進めたかご紹介します。同じような課題を抱えている方や新しくデータ基盤を作ろうとしている方の参考になると嬉しいです。 データ基盤の紹介 旧データ基盤の紹介 旧データ基盤の課題 変更があっても更新されないデータ 性質の異なるテーブルを同じ命名規則で管理 秘密情報の判断が手動 テーブルの取得元が不適切 新データ基盤の紹介 データレイクの構築 データセットを分けて管理 秘密情報を適切に管理 適切なDBからテーブルを連携する AWSにあったETLをGCPに移行 ワークフロー設計 コストとパフォーマンスの評価(Embulk vs Dataflow) Dataflow導入Tips DataflowのQuotas Dataflow同時実行ジョブ数 Compute Engine Dataflowの開発言語 データ基盤のさらなる発展と機能拡充 旧データ基盤からのお引っ越し 関係者と協力して新旧テーブル対応表を作成 配信チーム(オンプレDWH管理) データ分析チーム 基幹DB管理チーム 新データ基盤移行に伴うデータの評価 利用者への全体周知 新データ基盤移行で問題になったこと 過去データの扱い 参照しているクエリが減らない データマートの管理者が不明 まとめ データ基盤の紹介 冒頭でご紹介したとおり、ZOZOではオンプレのSQL ServerにあるテーブルをBigQueryに連携して利用しています。 データ基盤は大きく分けて2種類あり、日次でデータ連携してるものとリアルタイムにデータ連携しているものがあります。日次データ基盤ではSQL Serverの全量データを転送しています。リアルタイムデータ基盤ではSQL Serverで変更のあった差分データをBigQueryへ連携しています。 日次の全量データとリアルタイムな差分データを組み合わせることで、リアルタイムにBigQuery上でSQL Serverのテーブルの状態を再現できます。今回はこれらのうち日次データ基盤(以下、旧データ基盤と呼びます)をリプレイスした事例をご紹介します。 リアルタイムタイムデータ基盤については以前書いた以下の記事をご確認ください。オンプレDWHの移行に伴い連携するテーブルが増えたため、現在はGKE上で運用しています。 techblog.zozo.com 旧データ基盤の紹介 リプレイス対象の旧データ基盤についてご紹介します。旧データ基盤では中間DBをハブとして多段に連携していました。まず、オンプレの基幹DB(SQL Server)で管理されているテーブルを中間DB(SQL Server)に書き込みます。次に、中間DBをハブとしてBigQueryに書き込みます。 実はBigQueryの他にもう1つのオンプレDWHにも中間DBからテーブルを連携していました。このオンプレDWHは配信チーム(MA:マーケティングオートメーション)で管理しているDWHになります。配信系の処理など一部のサービスではオンプレのDWHを活用していました。 テーブルの連携処理はAWS上にあるDigdag(ワークフローエンジン)とEmbulk(ETLツール)を用いていました。 現在はオンプレDWHと旧データ基盤は廃止済みです。オンプレDWHの廃止の詳細は以下の記事をご確認ください。 techblog.zozo.com 旧データ基盤の課題 ここからは旧データ基盤の課題をご紹介します。以下の図にあるように旧データ基盤では基幹DBから中間DB、中間DBからDWHに連携するまでに様々な加工処理を施していました。具体的にどのような加工処理を施していたのか、運用上どのような課題があったのかご紹介します。 変更があっても更新されないデータ 旧データ基盤では以下の図にあるようなテーブルの更新タイムスタンプを使った差分連携を採用していました。 タイムスタンプを用いて変更のあった差分データに絞ることで、サイズの大きいテーブルでも低コストで高速に連携できます。しかし、実際に運用してみると、テーブルに変更があっても更新タイムスタンプが適切に更新されないケースがありました。そのため、基幹のテーブルに更新があってもクエリの条件に合致せず、変更のあったデータがBigQueryに反映されませんでした。 また、基幹テーブルのレコードが物理削除された場合削除されたデータを取得できません。それゆえ、基幹DBで削除されたデータがBigQueryから削除されずに残り続けていました。その他にも物理削除されたデータを残す連携など様々な加工処理を施していました。 そのため、BigQueryにあるデータが基幹テーブルのコピーになっておらず、データ不整合が発生していました。 性質の異なるテーブルを同じ命名規則で管理 1つのデータセットで、性質の異なるテーブルを同じ命名規則で管理していました。 例えば日付サフィックスをつけるテーブルがあります。 <テーブル名>_yyyymmdd この命名のテーブルを見たユーザは、全てのテーブルが同じ性質と考えてしまいます。例えば、最初に日付サフィックスの付いたテーブルが日次の全量スナップショットと認識したら、他のテーブルも同様と考えるでしょう。しかし実際には以下3種類の異なる性質のテーブルになっていました。 日次の全量スナップショット 日次の差分テーブル 日次の物理削除データを含むテーブル このように、1つのデータセット内で異なる性質のテーブルを同じ命名規則で管理していたため、利用者を混乱させていました。 秘密情報の判断が手動 旧データ基盤では秘密情報の判断を手動でしていました。秘密情報は秘密情報の管理表で管理されています。利用者がテーブルの追加を依頼する際に秘密情報の管理表を確認し、データ基盤の管理者側でも秘密情報がないか確認する運用です。旧データ基盤では秘密情報はBigQueryに入れない運用をとっていたので、秘密情報の場合はマスク処理を施してBigQueryで管理していました。 ただし、利用者の申請ベースの運用だったので、店舗の電話番号など秘密情報ではないのにマスク処理が施されている場合もありました。これまで幸い問題は発生していませんでしたが、人間任せだと誤って秘密情報を漏洩させてしまう懸念がありました。 テーブルの取得元が不適切 基幹DBにはマスタテーブル以外にもレプリされたテーブルがあります。レプリされたテーブルは同じ名前で異なるデータベース内に管理されています。 BigQueryへ連携する際に基幹DBのどのデータベースから連携するか決める必要があります。これまで、テーブルの取得元DBは利用者がテーブルの追加を依頼する際に指定する運用をとっていました。その結果、テーブルの取得元としてマスタDBとレプリDB両方から連携されていました。 冒頭で述べたようにZOZOではリアルタイムデータ基盤も運用しています。リアルタイムデータ基盤では鮮度の高いデータを取得できるようマスタDBから変更のあったデータをBigQueryに連携しています。 リアルタイムデータ基盤ではリアルタイムな差分データと日次の全量データを組み合わせることで、基幹テーブルの最新の状態を作りだしています。旧データ基盤の取得元が不適切だとリアルタイムデータ基盤と旧データ基盤(日次全量)で参照元のDBが異なり、不整合が発生してしまいます。 テーブル追加の依頼者もこうした事情を把握していないため、取得元は不適切になりがちでカオスな状態となっていました。 新データ基盤の紹介 このように様々な辛みがあったので、データ基盤をリプレイスしました。 新データ基盤は以下のような構成になっています。緑の点線部分が今回リプレイスした箇所になります。日次の全量データが正しいデータになったので、リアルタイムデータとマージした全量データも正しいデータにできました。ここからは新データ基盤をご紹介します。 データレイクの構築 新データ基盤ではBigQuery上にデータレイクを構築しています。旧データ基盤にあった特定の用途に特化した加工処理は全て廃止し、全量転送しています。無加工のデータレイクを用意し、特定の用途に特化した処理はデータマートで対応するようにしました。 データセットを分けて管理 新データ基盤では取得元DBとテーブルの性質を考慮して、データセットを分けて管理しています。 旧データ基盤では取得元DBや性質の異なるテーブルを全て1つのデータセットで管理していたため、利用者の混乱を招いていました。そこで、新データ基盤ではBigQueryのデータセットを以下のような命名規則にすることで対応しました。 <データベース名>_<性質> 以下の図にあるデータセット名で取得元DBとデータの性質を考慮して、データセットを分けています。例えば取得元DBが「zozob」の日次全量テーブルは「zozob_daily」、リアルタイム全量テーブルは「zozob_realtime」となっています。 秘密情報を適切に管理 新データ基盤では秘密情報の分類マスタに基づいて、秘密情報の判断を自動化しています。方法としては、分類マスタに基づいた適切なポリシータグと秘密情報のマスクカラムの追加によって実現しています。これによって、手動での秘密情報の判断が不要になりました。 新データ基盤ではメルマガ配信等どうしても秘密情報が必要な案件にも対応できるよう、秘密情報をBigQueryにいれています。自動化したことで安全に秘密情報を管理できるようになりました。 詳細は以下の記事にまとめたのでご確認ください。 techblog.zozo.com 適切なDBからテーブルを連携する 新データ基盤では取得元DBの判定処理を自動化しています。 基幹DBの管理チームと連携し、判定に必要なマスタ情報を用意しました。判定に必要なマスタ情報を用いることで、適切な取得元DBをクエリを用いて自動で判定できるようにしました。利用者がテーブルを追加する際に取得元DBを指定する運用をやめたことで、誤って意図しない中身のテーブルを用いて分析することを防げるようになりました。 AWSにあったETLをGCPに移行 新データ基盤ではAWSにあったETLをGCPに移行しています。当時RedshiftをDWHとして使っていた名残で、AWS上に構築された環境を使いデータ連携を実施していました。今回のリプレイスでAWSにあったETLをGCPに移行しました。ETLツールにはGCPが提供するDataflowを採用しました。Dataflowは負荷に応じてオートスケールしてくれるため、データ連携に必要なコストとパフォーマンスを最適にできます。 旧データ基盤ではバッチ連携前にDigdagのワーカ(EC2)を増やし、Embulkを用いてテーブルを連携していました。この仕組みの問題点として、全てのテーブル連携が完了するまでワーカをスケールインできませんでした。 旧データ基盤は差分連携により、転送するデータ量が少なかったため大きな問題はありませんでしたが、全量転送にしたことで新データ基盤ではコストが飛躍的にあがりました。旧データ基盤と同じ方法で全量転送するようにした場合、Dataflowを使うよりも月間で数百万、年間だと数千万以上のコスト増となってしまいました。 Dataflowを用いることで、ワーカのスケールアウトが不要になり、約85%のコスト削減に繋がりました。コストパフォーマンスの評価は後述しています。 ワークフロー設計 続いてDatafowを前提にしたワークフロー設計についてご紹介します。全体の処理の流れは以下の通りです。 Dataflowを採用したことでパフォーマンス最適化されるようワークフローを改善しました。Dataflowにしたことで、非同期ジョブでのテーブル連携が可能となりました。まず、Digdagで連携対象のテーブル数分並列にDataflowジョブを非同期に実行します。全てのテーブル数分Dataflowジョブを実行した後、後続のタスクでDataflowジョブが失敗していないか以下のように待ち処理を入れています。 def jdbc_to_bigquery_wait (self): project_id_public = workflow_repository.WorkflowRepository().find(key= 'GCP_PROJECT_ID_PUBLIC' ) project_id_private = workflow_repository.WorkflowRepository().find(key= 'GCP_PROJECT_ID_PRIVATE' ) project_id_private_bucket = workflow_repository.WorkflowRepository().find(key= 'GCP_BUCKET_NAME' ) region = workflow_repository.WorkflowRepository().find(key= 'DATAFLOW_REGION' ) worker_service_account = workflow_repository.WorkflowRepository().find(key= 'DATAFLOW_WORKER_SERVICE_ACCOUNT' ) database = workflow_repository.WorkflowRepository().find(key= 'DATABASE' ) table_name = workflow_repository.WorkflowRepository().find(key= 'IMPORT_TABLE_NAME' ) local_path = f 'job-id-{database}-{table_name}.txt' gcs_path = f 'job-id/{database}/{table_name}.txt' job_id = gcp_gcs_repository.GcpGcsRepository(project_id_private,project_id_private_bucket).read_from_gcs(local_path=local_path,gcs_path=gcs_path).replace( '"' , '' ) dataflow_job = deploy_dataflow_job.DeployDataflowJob(project_id_public, region, worker_service_account) state, message = dataflow_job.wait_dataflow_job(job_id) if state == False : time.sleep( 60 ) self.jdbc_to_bigquery_wait_until_finish() ジョブが失敗した場合、テーブル単位で同期的にリトライができるようワークフローを設計しています。以下のように同期的なリトライにすることでジョブが失敗したテーブルのみリトライできるようになります。 def jdbc_to_bigquery_wait_until_finish (self): project_id_public = workflow_repository.WorkflowRepository().find(key= 'GCP_PROJECT_ID_PUBLIC' ) region = workflow_repository.WorkflowRepository().find(key= 'DATAFLOW_REGION' ) worker_service_account = workflow_repository.WorkflowRepository().find(key= 'DATAFLOW_WORKER_SERVICE_ACCOUNT' ) dataflow_job = deploy_dataflow_job.DeployDataflowJob(project_id_public, region, worker_service_account) job_id = self.jdbc_to_bigquery() state, message = dataflow_job.wait_dataflow_job(job_id) if state == False : raise Exception (f '{message}' ) コストとパフォーマンスの評価(Embulk vs Dataflow) Dataflowに移行するにあたり、EmbulkとDataflowでコストや、パフォーマンスを比較評価しました。 Dataflowの方が負荷に応じてオートスケールするため、Embulkより高速に連携できると想定していました。しかし、調査したところEmbulkの方がパフォーマンス面では優れていました。 Dataflowのパイプラインを確認したところ、データの読み込みで時間がかかっていることがわかりました。どうやらJDBCを用いてRDBからデータを取得する際は分散処理ができないようでした。データ取得後の後続処理では分散処理が可能でした。一方Embulkでは、データ読み込みから連携までCPUを複数コア活用できていました。 比較ではEmbulkの方がパフォーマンス優位だったものの、Dataflowでもパフォーマンス要件は満たせていたので、コスト最適化できるDataflowを採用しました。 また、Embulkを採用する場合は、テーブルによっては10倍以上のディスク容量が必要になることもわかりました。RDBからとってきたデータをJSONでダンプするため、70GBのテーブルだと700GBまで膨れあがります。ディスク容量が枯渇すると転送処理が失敗します。詳細は以下のissueをご確認ください。 github.com github.com 私たちの場合はDataflowでも要件を満たせたのでDataflowを採用しましたが、ETLツールを利用する際は参考にしてみてください。 Dataflowにしたことで、データ連携前にDigdagワーカのスケールアウトも不要になりました。ワーカのスケールアウトは大きな障害ポイントだったので運用負荷も軽減されました。 費用対効果は大きく、年間で数千万以上のコスト削減にも繋がりました。以下の図から4月以降大幅にコスト削減されたことが分かります。 Dataflow導入Tips 次にDataflow導入Tipsをご紹介できればと思います。同じようにDataflowの採用を検討してる方の参考になると幸いです。 DataflowのQuotas 私たちの場合連携するテーブルが多かったため、GCP側でDataflowのQuotasを引き上げてもらう必要がありました。DataflowのQuotasと必要な対応をご紹介します。 Dataflow同時実行ジョブ数 Dataflowの同時実行ジョブ数はデフォルトだとプロジェクトあたり25になっています。連携対象のテーブルは600テーブルほどあったので、今後のことも考え上限を1000に上げてもらいました。サポートケースを起票し用途を伝えれば引き上げてもらえます。引き上げてもらったQuotasはGCPコンソールの「IAM & admin」から「Quotas page」より確認できます。 cloud.google.com Compute Engine DataflowはCompute Engine上で動くため、Compute EngineのQuotasも引き上げる必要があります。ゾーンのN2_CPUSの上限は200だったので2000に引き上げました。この制限は「IAM & admin」から「Quotas page」よりすぐ引き上げられました。 2022-03-06 02:16:04.440 JSTStartup of the worker pool in zone asia-northeast1-b failed to bring up any of the desired 1 workers. QUOTA_EXCEEDED: Instance 'jdbc-to-bigquery-zozoold--03050914-6bui-harness-dvc9' creation failed: Quota 'N2_CPUS' exceeded. Limit: 200.0 in region asia-northeast1. Dataflowの開発言語 DataflowはJava、Python、Goをサポートしています。私たちはJavaを選びました。Python版を検証をしたところ、クエリの上書きができなかったり、余計な通信が走ったりとまだ本番で利用するには不十分だったからです。 詳しくは以下の記事にまとめました。 www.case-k.jp www.case-k.jp Java版はGCPでテンプレートを提供してくれています。しかし、テーブルの上書き(WRITE_TRUNCATE)ができず、タイムスタンプの加工処理が誤っていました。MySQLなど特定のデータソースに特化した加工処理も含まれていたので、カスタムテンプレートを作成しました。 https://github.com/GoogleCloudPlatform/DataflowTemplates/blob/main/src/main/java/com/google/cloud/teleport/templates/JdbcToBigQuery.java#L105 github.com github.com 以下のようなDataflowのパイプラインを作り、SQL ServerやMySQLからデータを連携しています。 pipeline .apply(JdbcIO.<TableRow>read() .withDataSourceConfiguration( JdbcIO.DataSourceConfiguration.create(driver_class_name, jdbc_url) .withUsername(username) .withPassword(password) ) .withQuery(query) .withCoder(TableRowJsonCoder.of()) .withRowMapper( new ResultSetToTableRow(options.getTimezone()))) 新しく追加されたカラム等反映できるよう、BigQueryのテーブルを上書きする場合はスキーマ情報が必要です。Digdagでデータ連携する際、対象テーブルのスキーマ情報を取得し、GCSにアップロードしています。GCSにアップロードされたテーブルのスキーマ情報を用いて、書き込み先のテーブルを全量置換しています。 .apply( "Write to BigQuery" , BigQueryIO.writeTableRows() .withoutValidation() .withCreateDisposition(BigQueryIO.Write.CreateDisposition.CREATE_IF_NEEDED) .withWriteDisposition(BigQueryIO.Write.WriteDisposition.WRITE_TRUNCATE) .withCustomGcsTempLocation(options.getBigQueryLoadingTemporaryDirectory()) .withSchema( NestedValueProvider.of( options.getSchema(), new SerializableFunction<String, TableSchema>() { @Override public TableSchema apply(String jsonPath) { TableSchema tableSchema = new TableSchema(); List<TableFieldSchema> fields = new ArrayList<>(); SchemaParser schemaParser = new SchemaParser(); try { JSONArray bqSchemaJsonArray = schemaParser.parseSchema(jsonPath); for ( int i = 0 ; i < bqSchemaJsonArray.length(); i++) { JSONObject inputField = bqSchemaJsonArray.getJSONObject(i); TableFieldSchema field = new TableFieldSchema() .setName(inputField.getString(NAME)) .setType(inputField.getString(TYPE)); if (inputField.has(MODE)) { field.setMode(inputField.getString(MODE)); } fields.add(field); } tableSchema.setFields(fields); } catch (Exception e) { throw new RuntimeException(e); } return tableSchema; } })) .to(options.getOutputTable())); pipeline.run(); } データ基盤のさらなる発展と機能拡充 日次連携データが正しいデータになったことで、依存関係のあったリアルタイムデータ基盤も正しい状態を作り出せるようになりました。データを整備したことで、AI等様々なデータを活用した分野で活用できます。 リアルタイムデータ基盤の差分データと日次の全量データを組み合わせたタイムトラベル機能もその1つです。正しいデータ扱えるようになったことで、特定時刻のテーブルを再現できるようになりました。 詳しくは以下の記事にあるのでご確認ください。 techblog.zozo.com 旧データ基盤からのお引っ越し 新データ基盤の構築後はお引っ越しです。利用者に旧データ基盤を参照しているクエリを安全に書き換えてもらう必要があります。 旧データ基盤と新データ基盤で差分があまりないなら大きな問題にはなりません。しかし、数億件以上のレコードの乖離がある場合もあるため、注意が必要です。 安全に移行を進めるため、関係者と協力し、新旧データ基盤の差分や移行先をまとめたテーブル対応表を作りました。新旧テーブル対応表を作成したのち、複数回に分けて利用者に周知しました。 どのように引越し作業を進めたかご紹介できればと思います。 関係者と協力して新旧テーブル対応表を作成 クエリの書き換えをしてもらうために、利用者を特定し、新旧テーブルの差分をまとめた移行対応表を作成します。クエリを1ファイルずつ確認しながら差分を調べるなかなか泥臭いところです。 新旧テーブル対応表の作成するために、様々なチームとの協力が必要でした。各チームと協力し、最終的には以下のような新旧テーブル対応表を作り、利用者に展開しました。各チームとの取り組みについてご紹介します。 配信チーム(オンプレDWH管理) まずはオンプレDWHを廃止するため、オンプレのDWHを管理する配信チームと協力して移行を進めました。 元々配信チームとデータ基盤は同じチームだったこともあり、ワークフローが混在している状態でした。例えばオンプレDWHにあるデータマートの更新処理を両チームのワークフローから実施していました。 配信チームと持ち物を整理し、利用者とも相談しながら、移行に必要な機能や仕組みを作りました。先行してオンプレDWHの移行を進めたので、このフェーズで新データ基盤に必要な機能は一通り揃いました。 オンプレのDWHの移行の詳細は以下の記事をご確認ください。 techblog.zozo.com データ分析チーム 次にデータ分析チームです。データ分析チームでは旧データ基盤のテーブルを使い、データ分析やデータ分析に必要なデータマートを作成していました。 データマートはデータ分析チーム以外に事業部でも作られていました。データマートを構築するために参照してるテーブル先が変わるため、クエリの書き換えが必要になります。ビジネスサイドとも連携が必要なので、移行に伴うビジネスサイド側のディレクションはデータ分析チームに実施してもらいました。 データ分析チームとの定例は新データ基盤への移行完了後も継続して実施しています。これまでデータ分析チームの要望をヒアリングする機会がなかったので良い機会となりました。 基幹DB管理チーム 新データ基盤の移行にあたり、旧データ基盤の課題を解決するために基幹DB管理チームと協力して、マスタ情報等を整理しました。すでにご紹介したとおり旧データ基盤の負債を解消するためにマスタ情報の整理が必要でした。基幹DB管理チームと連携しマスタ情報を整理し、新データ基盤に必要な仕組みを導入していきました。また、データ欠損や重複を防ぐための仕組みも導入してもらいました。 詳細はテックブログでも公開してるのでご確認ください。 techblog.zozo.com techblog.zozo.com 新データ基盤移行に伴うデータの評価 新データ基盤で移行に必要なテーブルが準備できたあとはデータの評価が必要です。移行に伴いデータ欠損等発生している場合があります。 オンプレのDWH移行では移行前後のデータを評価できる仕組みを入れたことで、移行に伴う欠損を未然に防ぐことができました。配信ログ等並行運用が難しい場合は過去データだけでも評価できるよう仕組みを整えることで、過去データを移行する際に発生してログ欠損を未然に防ぐことができます。 また、データ分析チームにも協力してもらうことで移行に必要なデータが不足していないかを確認しました。様々な加工処理を施した影響で過去データが不足している場合もありました。利用者にクエリを書き換える前に評価することで、事故を未然に防げます。 利用者への全体周知 新データ基盤の評価が完了し、新旧テーブル対応表が作成できたあとは利用者全体への周知です。共通基盤の利用者300人ほどをSlackのチャンネルに招待し、3回に分けて説明会を開きました。 利用者はBigQueryから利用状況を確認し、サービスアカウントの管理者はKintoneやSlackの履歴から特定しました。周知を行い説明会やSlackにて利用者からの疑問に回答していきました。データ分析チームと事前に不明点は調べ、新旧テーブル対応表や資料にもまとめていたので、想定外の問い合わせはありませんでした。新旧データ基盤の差分や移行先等まとめていたのである程度スムーズ進みました。 新データ基盤移行で問題になったこと ここからは全体周知後、問題になったことをご紹介します。 過去データの扱い ある程度予想はしていましたが、想像以上に過去データを引き続き使いたいとの要望があがりました。過去データは様々な加工処理が施され、信用できないデータになっています。どうしても必要な場合は別のGCPプロジェクトへコピーして管理してもらおうと思っていました。しかし、利用者からの要望が多かったため、別のGCPプロジェクトにバックアップをとり別環境で引き続き利用できるようにしました。データは信用できなくても、過去データが使えなくなるデメリットよりはましとの判断です。長期的には破棄していければと思います。 参照しているクエリが減らない 新データ基盤への移行を社内で周知しても、移行期限までにクエリの書き換えは思うように進捗しませんでした。止むを得ず、一時的に旧データ基盤のテーブルをビューに置き換え、新旧データ基盤の差分を吸収する対応をしました。 また、チームによっては旧データ基盤を参照しているクエリを止めない判断をする場合もありました。BIツールから実行されているクエリ等不要なクエリの棚卸しに時間がかかるためです。 さらに、GCPのデフォルトサービスアカウントを複数のチームで共用している場合、安全に移行することが困難になります。同じサービスアカウントを使うと、BigQueryの実行ログから移行が完了しているか確認できないためです。 案件ごとに必要最低限の権限をもったサービスアカウントを作らないと安全に移行することは難しいです。前もってデフォルトサービスアカウントの撲滅等を優先し実施した方が良かったです。 期日に余裕があれば参照クエリをなくすことは可能ですが、期日が決まってる場合は完全に参照クエリをなくすことは難しいように思いました。 データマートの管理者が不明 誰が管理者なのか不明確なデータマートがありました。退職等でデータマートの管理者が不在の場合があります。管理者不在のデータマートも他のチームのクエリからは参照されている場合があり、移行にともない混乱がありました。利用する側もそうですが、データマートの管理者がだれなのか品質を担保するために把握できる仕組みが必要に思いました。 まとめ 本記事では古くなった共通データ基盤をリプレイスする事例をご紹介しました。実際にリプレイスしてみると記事では書ききれない泥臭さもありましたが、旧データ基盤の負債も解消できてよかったです。旧データ基盤の負債を解消するために、マスタ情報の整理や運用の見直し、進め方等様々なチームと連携し進めました。関係者のみなさんにも感謝です。 本記事を読んで、もしご興味をもたれた方は、是非採用ページからご応募ください。 hrmos.co 最近配信チームに異動したのでこちらも是非! hrmos.co
アバター
はじめに こんにちは。ZOZOTOWN開発本部フロントエンドの菊地( @hiro0218 )です。 現在、 ZOZOTOWN ではWebフロントエンド技術のリプレイスプロジェクトが進行しています 1 。本記事では、WebフロントエンドのリプレイスでCSS in JSの技術選定をした際の背景や課題についてご紹介します。 既存技術スタックの課題 リプレイス以前の環境は、Classic ASPのテンプレートエンジンに依存したUI実装が多く存在しており、新規開発や変更のタイミングで実装をReact + CSS Modulesへ改修しています。そのため、レガシーな実装とモダンな実装が共存した状態です。 こういった背景から、リプレイス以前のUI開発では以下のような課題がありました。 グローバルなCSSが多く、CSSの変更がどこへ影響するのか予測しづらい Classic ASPのテンプレートエンジンに依存したUI実装が多く存在しているため CSS Modulesの課題 近い将来、非推奨になる可能性がある css-loaderのCSS Modulesはメンテナンスモードになっており 2 、再リプレイスを視野に入れないで済むように活発な技術にしたい コンポーネント側でクラス名が間違っていてもエラーが発生しない .d.ts ファイルを自動生成してクラス名の補完やエラーが分かるようにはしていた スタイルの優先度に保証がない 管理コストがかかる CSSファイルとJSファイルが別なため ローカルスコープ(CSSクラス名を衝突させない)になる設定を外していた ページ単位でグローバルなCSSから上書きする必要があった 3 CSS設計を用いてクラス名が競合しないようにしていた CSS Modulesを利用していますが、レガシーな実装とモダンな実装の整合性を取るために、CSSのクラス名をローカルスコープに出来ていませんでした。いずれにしてもReactコンポーネントの再開発が必要になり、リプレイス時に過去の資産を完全に活かし切るのも難しい状況であったため、技術スタックの再考の余地はあると考えました。 リプレイス以前の環境における課題や背景については、「 ITCSS を採用して共同開発しやすい CSS 設計を ZOZOTOWN に導入した話 」でも触れております、興味のある方は併せてご確認ください。 techblog.zozo.com なぜCSS in JSを使うのか CSS in JSとは、コンポーネントに属するCSSをバンドルさせるためのアプローチです。CSS in JSを利用することで、CSSはコンポーネントに定義され、外部のCSSファイルに依存することなくコンポーネント単体で独立して動作させることができます。 既存技術スタックの課題でも挙げましたが、これまではグローバルなCSSを利用しており、CSSの定義を変更した際にどこへ影響があるか予測しづらいという課題がありました。CSS in JSを利用することで、CSSの変更による影響をコンポーネント内に留めることができます。 宣言的にUIを実装できるReactとの親和性も高いです。 CSS in JSの選定基準 CSS in JSのライブラリを選定する上で基準としたものは以下になります。 タグ付きテンプレートリテラル記法が使えること メンバーがキャッチアップしやすいことを前提として、通常のCSSの使用感を変えたくない オブジェクトスタイル記法だと既存実装を移植する際に難がある Sass(SCSS)記法のようなネストセレクタが使えること TypeScriptとの親和性があること 参考資料が多いこと メンテナンスが活発であること CSS in JSの選定候補 いくつものCSS in JSライブラリを確認しましたが、使用感など選定基準にマッチしなかったものも多く、最終的に以下のライブラリが選定候補となりました。 Linaria Styled Components Emotion Linaria(Zero-runtime CSS in JS)を検証する パフォーマンスの観点で考えるとZero-runtime CSS in JSは理想的です。その中でもStyled ComponentsやEmotionなどの先発ライブラリと同様の構文を備えているLinariaは有力な候補のひとつでした。 しかしながら、検証していくうちに以下のような課題が出てきました。 Linariaは動的なスタイルを使用した場合にCSS Custom Propertiesを出力するため、多用するとCSSが肥大化してしまう 動的なスタイルの値が undefined な場合に不要なプロパティが残ってしまう const Heading = styled.h1` background-color: ${({ bg }) => bg}; `; const Example = () => { return ( <> <Heading bg="red">Red Heading</Heading> <Heading>Heading</Heading> </> ); }; <!-- 出力後 --> <h1 class="tzzg9j5w" style="--tzzg9j5w-0:red;">Red Heading</h1> <h1 class="tzzg9j5w" style="--tzzg9j5w-0:undefined;">Heading</h1> <style> .tzzg9j5w { background-color: var(--tzzg9j5w-0); } </style> 同じコンポーネントをネストすると再利用されたCSS Custom Propertiesが上書きされていまい意図した動作をしない const Stack = styled.div` & > * + * { margin-top: ${({ spacing }) => spacing || "0"}; } `; const Example = () => { return ( <Stack spacing="1rem"> <Stack></Stack> <Stack></Stack> {/* ここの margin-top が 0 になってしまう */} </Stack> ); }; ネストされた2番目の Stack の margin-top は 1rem となることを期待するが、CSS Custom Propertiesの上書きによって 0 になってしまう - 現時点で他社の採用事例やドキュメントがまだ少ない ZOZOTOWNは複雑なアプリケーションな上に開発メンバーも多く、上記のような課題が発生してしまうと開発の足かせになってしまいかねないため、今回は導入を見送りました。 Styled ComponentsとEmotionを比較する 選定候補はStyled ComponentsもしくはEmotionの2つになりました。この両ライブラリの比較検討の際に行った比較方法を紹介します。 機能面の比較 以前はStyled Componentsに比べ、後発のEmotionの方が利用できる機能は多かったようです。ですが、調べてみると現状はStyled ComponentsもEmotionと同様の機能があるようでした。 ライブラリ タグ付きテンプレート オブジェクトスタイル グローバルスタイル ネストセレクタ Theme Provider TypeScriptサポート Server Side Rendering Styled Components ✅ ✅ ✅ ✅ ✅ ✅ ✅ Emotion ✅ ✅ ✅ ✅ ✅ ✅ ✅ 両ライブラリとも必要としていた機能はありました。 トレンドの比較 ライブラリのスター数(GitHub)は先発ライブラリに分がありますし、いくらスター数が多くても今は積極的に使われていない可能性もあります。今のダウンロード数を見てみれば、そういった可能性を排除できるため、 npm trends からダウンロード数の推移を確認してみたいと思います。 npmtrends.com Emotionは機能別にパッケージが分かれているため、Styled Componentsと同機能を持つ @emotion/styled と styled-components で比較しました。 npm trends - @emotion/styled vs styled-components 比較した結果、後発のEmotionが途中からStyled Componentsのダウンロード数を追い越して安定しているようでした。大きな差があるとは言えませんし、ライブラリの比較条件が異なっているため、npm trendsの比較だけでは、両者の優劣を判断できないと考えました。 次に、 The State of CSS 2021 を見てみます。 The State of CSS は、世界中の開発者へアンケートを実施し、その回答の集計結果を公開することで技術選定やトレンドを見つけることを目的としたサイトです。 2021.stateofcss.com その中から CSS-in-JS の項目を参考にユーザーの声を見てみたいと思います。 Styled ComponentsとEmotionを抜粋すると以下の通りです。 項目 Styled Components Emotion 満足度 (また使いたいvsもう使わない) 77% (4位 ⬇) 74% (6位 ⬇) 興味 (学びたいvs興味がない) 54% (5位 ⬇) 42% (10位 ⬇ ) 利用率 (また使いたい + もう使わないvs認知度) 52% (1位 ➡) 19% (4位 ➡) 認知度 (総数 - 聞いたことがない) 87% (1位 ➡) 49% (4位⬆) ユーザー数や満足度のいずれもStyled Componentsの方がEmotionを上回っている結果が見られました。 歴史の長さからStyled Componentsの利用率や認知度は非常に高いようです。後発のEmotionについてもStyled Componentsのすぐ後に登場しているため大きく状況は変わらないでしょう。両ライブラリの満足度や興味は下がっている一方で利用率や認知度は年を経ても大きく変化がないのは、新しいライブラリの台頭に押されているのだと推察できます。実際、既存ライブラリが上回ったわけではなく、新しいライブラリが上位に来ています。両ライブラリは、CSS in JSライブラリとして枯れつつある状況だという印象を受けました。 パフォーマンスの比較 両ライブラリのパフォーマンスを比較検証した記事を見ると「Emotionのパフォーマンスの方が高い」という結果が散見されました(Emotionは公式でも高パフォーマンスを謳っています 4 )。しかし、記事の検証時期が古くなっているものも多く、現在のバージョンでどちらのパフォーマンスが高いのかは判断しづらくもありました。 そういう背景から各ライブラリの最新バージョンでベンチマークを測定しました。ベンチマークは、ReactのUIコンポーネントライブラリである MUI (旧名:Material UI)が公開 5 しているものを利用して測定しました。 測定条件 ライブラリのバージョン @emotion/styled : v11.10.0 styled-components : v5.3.5 実行条件 5000個のコンポーネントを描画する <> {new Array(5000).fill().map(() => ( <Div>test case</Div> ))} </> 20回繰り返して計測 実行マシンのスペック MacBook Pro (2019) プロセッサ:2.6GHz 6-CORE i7 メモリ:32GB 2667 MHz DDR4 測定結果 ライブラリ 1 2 3 4 5 Styled Components (styled) 196.73 ±08.10ms 192.29 ±06.04ms 204.88 ±44.59ms 197.84 ±14.31ms 194.33 ±07.25ms Emotion (styled) 190.96 ±09.97ms 189.03 ±13.87ms 180.45 ±07.79ms 191.86 ±10.56ms 191.20 ±09.80ms 測定結果をみるとEmotionの方が良いパフォーマンスでした。様々な条件で確認したところ、特に描画するコンポーネント数が多い場合はEmotionの方が高パフォーマンスという結果が見られました。 near-zero runtimeを謳う Stitches というCSS in JSライブラリの ベンチマーク結果 にEmotionとStyled Componentsも比較対象として載っています。Stitchesに軍配が上がってはいますが、そちらでもStyled ComponentsよりEmotionの方が高パフォーマンスという結果が出ていました。 バンドルサイズの比較 Styled Componentsのパッケージは1つのパッケージになっていますが、Emotionはパッケージが用途別に分かれています。そのため、例えば @emotion/styled を使うか否かで当然バンドルサイズも変わります。そのため、単純なパッケージ同士のバンドルサイズの比較は難しいため、今回は利用するであろうパッケージを揃えて比較しました。 パッケージ バンドルサイズ Styled Components 46.07KB → 17.02KB (gzip) Emotion ( @emotion/react , @emotion/styled ) 43.78KB → 16.21KB (gzip) 単純なバンドルサイズでの比較では、Emotionの方が僅かにサイズが少ないようでした。 まとめ スタイリングを行うためのアプローチとしてはCSS in JS、ライブラリはEmotionを選択しました。 CSS in JSを選択した理由: スタイルがコンポーネントと紐づくため、関心の分離 6 が行われる ローカルスコープなCSSクラス名が自動生成されるため、コンポーネント同士が影響し合わない JavaScriptの変数・関数と統合できるため、CSSの変数や関数よりも、コンテキストに基づいた動的なスタイリングがしやすい Emotionを選択した理由: 機能要件を満たせていた パフォーマンスやバンドルサイズの観点でStyled Componentsよりも優れていた 著名なライブラリのため、利用率も高く情報のキャッチアップがしやすい 他社の技術記事でEmotionの採用事例をいくつか見られた ReactのUIライブラリ( MUI 、 Chakra UI など)が内部的にEmotionを利用しており、エッジケースの実装が参考になった 初めてCSS in JSに触れるメンバーでもEmotionを利用した開発は参入障壁にはならず、スムーズに開発を進めることができました。必要に応じてメンバー自らキャッチアップしながら進めていくことも出来ています。署名なライブラリのためキャッチアップしやすいことが功を奏していると感じています。 フロントエンドの技術スタック全般に言えることでもありますが、The State of CSSでの推移を見ても分かる通り、CSS in JSライブラリの移り変わりは早いです。技術選定は「どのようなスキルセットの開発メンバーが関与していくのか」「選定したライブラリが参入障壁にならないか」「ライブラリの情報はキャッチアップしやすいか」などを重視しました。開発メンバーにとっても納得感のある技術選定をすることの重要性を再認識できました。 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co hrmos.co ZOZOTOWNリプレイスにおけるWebフロントエンドのこれから / The future of web frontend in ZOZOTOWN replacement - Speaker Deck ↩ webpack-contrib/css-loader: Interoperability across tools and support plain JS modules imports ↩ ITCSSを採用して共同開発しやすいCSS設計をZOZOTOWNに導入した話 - ZOZO TECH BLOG ↩ Emotion - Performance ↩ https://github.com/mui/material-ui/tree/master/benchmark/browser ↩ マークアップとロジックを別々のファイルに書いて人為的に技術を分離するのではなく、マークアップとロジックを両方含む疎結合の「コンポーネント」という単位を用いて関心を分離します。 ↩
アバター
はじめに こんにちは、計測プラットフォーム開発本部SREブロックの渡辺です。普段はZOZOMATやZOZOGLASSなどの計測技術に関わるシステムの開発、運用に携わっています。 先日私達のチームでは、EKS環境にArgo CDを導入し、デプロイパイプラインのリアーキテクトを行いました。 開発環境では、Argo CD Image Updater(以下、Image Updaterとする)を活用したスピーディなデプロイ設計をしました。詳しくは「EKS環境へArgo CD Image Updaterを導入し、デプロイ時間と管理コストを削減した話」を参照ください。 techblog.zozo.com 本記事では、Argo CD導入による本番環境のリリースフロー設計やタグ更新の仕組みなど工夫した点について紹介します。Argo CDを検討している方に向けて、少しでも参考になれば幸いです。 目次 はじめに 目次 Argo CD導入前の課題 Argo CD導入 Argo CDのProject設計 GitHubリポジトリ設計 ブランチ戦略 CI/CD設計 イメージタグの更新方法 Image Updaterの考慮すべき点と対応 ロールバック 導入前後の比較 まとめ 終わりに Argo CD導入前の課題 ここでは例としてZOZOMATにおけるArgo CD導入前のCI/CDアーキテクチャを下図に示します。 Argo CD導入前までは、以下の手順でリリースしていました。 CI アプリケーションの変更をmainブランチへ取り込む CircleCIのイメージビルド用のジョブが発火する skaffold buildを実行し、イメージをビルドしてECRにプッシュする ビルド時に生成されたイメージタグが記録されたjsonファイルをS3にアップロードする CD ビルド完了を待ち、CDをトリガーするスクリプトを実行する(引数でデプロイ先の環境を指定) CircleCIからCodePipelineのアクションプロバイダーであるS3にソースコードをアップロードする CodeBuildがソースとイメージタグファイルをS3から取得し、Skaffoldを使ってapplyする 旧CI/CDの問題点については、 先の記事 でも説明していますが、本番環境も同様に大きな課題はCD部分にありました。 開発環境ではスピーディなデプロイが求められましたが、本番環境では以下のようなデプロイ作業の不安定さがより課題感としてありました。 CIの完了確認やデプロイする環境はリリース担当者が判断する CDのトリガーをリリース担当者が手作業で行う 各プロダクトごと作業内容が異なる(ZOZOMATとZOZOGLASSなど) 問題発生時の調査範囲が広くなり、ロールバック判断が遅れる 横展開の工数が増える(0->1でCI/CDを構築する必要がある) 新規にジョインしたメンバーのキャッチアップコストが大きい Argo CD導入 上記に挙げたCDの課題を解決するため、Argo CDを導入することにしました。 Argo CDは、Kubernetes環境での GitOps を実現するためのCDツールです。Gitリポジトリで管理しているKubernetesマニフェストを監視して、Kubernetesクラスタに適用します。Gitを信頼できる唯一のソースとすることで、Kubernetesクラスタ内の状態を管理するものです。 GitOpsを実現するツールとして、Argo CDの他にもFluxやJenkins Xなどが挙げられます。この中でArgo CDを採用した大きな理由は、「Syncの状況が把握しやすい」「GUIの操作で特定のリソースだけをSyncできる」などGUIが優れている点になります。また、リリースはSREだけでなくバックエンドチームも担当するため、わかりやすいツールで運用することは上で重要なポイントだと判断しました。 それでは、私達がArgo CDを導入するにあたり検討した以下の内容について説明します。 Argo CDのProject設計 GitHubリポジトリ設計 ブランチ戦略 CI/CD設計 イメージタグの更新方法 Argo CDのProject設計 Argo CDにはProjectという概念があります。ProjectはApplicationをグループ化するものです(ApplicationはGitOpsするGitリポジトリの設定)。 Projectには主に以下の機能があります。詳しくは 公式ドキュメント を参照してください。 デプロイできるソース (GitHub) を制限する機能 デプロイする対象 (Clusterやnamespace) を制限する機能 デプロイできるObject(Kubernetesリソース)を制限する機能 操作権限を制限するRBACを提供する機能 今回、私達が厳密に制御したかったのはデプロイ対象のnamespaceでした。 これは、私達が単一のEKSクラスタの上で複数のサービスを運用するシングルクラスタ・マルチテナント構成を採用していることが大きな理由になります。シングルクラスタ・マルチテナント運用については、「EKSのマルチテナント化を踏まえたZOZOGLASSのシステム設計」を参照してください。 techblog.zozo.com 1つのクラスタにプロダクト毎namespaceを作成しているため、 あるプロダクトのリソースが別のプロダクトのnamespaceにデプロイできない制限を設ける必要がある のです。 そこで、上記のうち2番目の「デプロイする対象 (Clusterやnamespace) を制限する機能」に関して、Projectの機能を利用したnamespaceの制限について紹介します。 Argo CDは初期設定としてdefaultという名前のProjectが作成されてますが、こちらは制限なしのProjectです。全てのApplicationをdefault Projectで管理する問題として、どのApplicationからでも複数のnamespaceにデプロイできてしまうことが挙げられます。 例えば、ZOZOMATをzozomat namespace、ZOZOGLASSをzozoglass namespaceで管理している場合、ZOZOMATのApplicationからzozoglass namespaceにデプロイできる状態になってしまいます。 そこで私達は、これを防止すべく下図のように1namespace 1Projectで設計しました。 設計当初は1Application 1Projectでよりセキュアにする考えもあったのですが、この設計ではApplicationのグループ化を行えないデメリットがありました。 今後namespace内でマイクロサービス化する可能性があり、1つのnamespaceに複数のApplicationを設定することが予想されます。このため、1namespace 1Projectで管理することが望ましいと考えました。 ただ、今回はArgo CD導入初期として1namespace 1Projectで設計しましたが、今後プロダクトで共通利用するnamespaceを作成する可能性もあります。このため、変化する状況に合わせて柔軟に対応していきたいと思います。 GitHubリポジトリ設計 計測システム部では、バックエンドとSREは別のチームとして存在しています。以前はプロダクトごとにアプリケーションリポジトリ(以下、アプリリポジトリ)の中でKubernetesマニフェストを管理していました。 私達が1つのリポジトリで管理して実感した課題は以下のとおりです。 アプリケーションの変更を追跡しづらい コミット履歴にKubernetesマニフェストの変更がノイズとして入る ロールバックする際、アプリケーションは戻したいけどKubernetesマニフェストは戻したくない場合に面倒 Kubernetesマニフェストの変更でもCI(イメージビルド)が動く podの数を変えたいだけなのにアプリケーションのテストが走るストレス リポジトリ運用をインフラとバックエンド間で調整する必要がある ブランチ戦略を決める際に双方の要望を叶えようとするとリポジトリ設定やCI定義等が複雑化しがち アプリリポジトリに本番環境を変更する仕組みや権限を配置する必要がある Argo CDのベストプラクティス では、 アプリケーションのソースコードとKubernetesマニフェストを分けて管理することが推奨されています 。このため、プロダクトごと新たにKubernetesマニフェストを管理するリポジトリ(以下、Kubernetesリポジトリ)を作成しました。 チーム単位でリポジトリを分けたことで、上記のデメリットを解消できました。 ブランチ戦略 ここではKubernetesリポジトリのブランチ戦略を紹介します。 Argo CDは、Applicationの設定においてターゲットとしてリポジトリのブランチを指定できるため、環境ごとにブランチを分けることで1つのリポジトリで複数環境を構築できます。 そこで、私達はmainブランチをステージング環境、releaseブランチを本番環境として運用しています。デフォルトブランチをmainに設定し、PRの向き先をmainブランチへ指定します。 本番環境のターゲットとなるreleaseブランチはmainブランチを追従し、ステージング環境で動作確認してから本番環境に反映します。 余談ですが、アプリリポジトリと分けたことで、ブランチ戦略がバックエンドチームと競合しなくなりました。ブランチ戦略の決定権がSREチーム内にあるため、他チームとの調整が不要になり設定が楽になりました。 CI/CD設計 それでは、本記事のメインとなるArgo CD導入後の本番リリースフローについて説明します。 開発環境との大きな違いは、承認フローを設けているところです。 先の記事 で説明していますが、開発環境では作業効率を優先したためImage Updaterを活用した承認フローなしのデプロイを行っています。 しかし、 本番環境ではデプロイ時間の短さよりも安定性を求めているため、承認フローは必須条件です 。このため開発環境とは別の仕組みでリリースフローを検討する必要がありました。 通常、アプリケーションをデプロイするためには、アプリリポジトリでビルドしたイメージをKubernetesマニフェストに反映させる必要があります。 ここでは、Push型の方法としてアプリリポジトリのCIでKubernetesリポジトリを取り込み新しいタグに書き換え、変更コミットを含むブランチを作成する仕組みを構築することが一般的かと思います。 私達も当初はそのような仕組みを考えていました。しかし、開発環境で導入したImage Updaterに イメージリポジトリの変更を検知して、イメージを変更するコミットを含むブランチを自動作成する機能 があることを知りました。こちらはPull型の方法で、ECRなどをImage Updaterが監視して最新のイメージを検知した際、Push型同様Kubernetesリポジトリに変更コミットを含むブランチを作成するものです。 今回は、検証の意味も含めImage Updaterを活用した仕組みを構築しました。イメージタグ更新の仕組み下図に示します。 アプリリポジトリでビルドしたイメージがECRにプッシュされる Image Updaterが最新のイメージを検知 Image UpdaterからKubernetesリポジトリにタグ更新コミットつきブランチを作成 GitHub Actionsでブランチ作成をトリガーにmainブランチ向けリリースPRを作成 アプリリポジトリのCIはイメージをECRにプッシュするまでを責務とし、イメージを検知したImage UpdaterがKubernetesリポジトリに変更を通知するような構成になっています。Kubernetesリポジトリ側は、通知をトリガーにGitHub Actionsでタグを更新するPRを作成するというのが大まかな流れになります。 イメージタグの更新方法 それでは、上記の仕組みの構築方法について説明していきます。 まずは事前準備として、ステージング環境にImage Updaterをインストールし、Argo CDに設定しているApplicationのannotationsに以下の設定を追記します。 metadata : annotations : argocd-image-updater.argoproj.io/write-back-method : git argocd-image-updater.argoproj.io/git-branch : main:image-updater-{{.SHA256}} argocd-image-updater.argoproj.io/image-list : my-image=<AWS Account>.dkr.ecr.<Region>.amazonaws.com/<ECR Repo> argocd-image-updater.argoproj.io/my-image.update-strategy : latest argocd-image-updater.argoproj.io/my-image.ignore-tags : latest こちらの設定により、イメージを検知したImage Updaterが変更コミットつきブランチを作成します(この他にも柔軟な設定ができるので、詳しくは 公式ドキュメント を参照してください)。 なお、Image UpdaterはECRのイメージ情報を取得する権限が必要になります。ここでは詳しく説明しませんが、気になる方は 先の記事 を参照してください。 それでは、Image UpdaterがKubernetesリポジトリにどのようなアクションを行うのか見ていきましょう。 私達のチームでは環境差分を管理しやすくするためkustomizeを用いており、overlaysディレクトリの中で各環境のディレクトリを作成しマニフェストを管理しています。 最新のイメージを検知したImage Updaterがoverlays/stagingディレクトリにあるイメージ管理ファイルのタグを変更するコミットつきブランチを作成します。 イメージ管理ファイルは、 .argocd-source-<Application名>.yaml という名前で以下のような内容になります。 kustomize : images : - <AWS Account>.dkr.ecr.<Region>.amazonaws.com/<ECR Repo>:<Tag> この時点では、まだmainブランチのファイルを更新していないため、ステージング環境のPodが入れ替わることはありません。 次にGitHub ActionsでImage Updaterが作成したブランチをトリガーに、mainブランチ向けステージングリリースPRを作成します。 先ほど紹介したArgo CDの各Applicationのannotationsのうち、以下の設定によりimage-updaterから始まるブランチが作成されます。 argocd-image-updater.argoproj.io/git-branch: main:image-updater-{{.SHA256}} そして、GitHub Actionsのトリガーを以下のように設定することで、Image Updaterが作成したブランチのみをトリガーにアクションが実行されるようにします。 on : push : branches : - 'image-updater-**' GitHub Actionsでは、「overlays/productionディレクトリにある本番環境用イメージ管理ファイルをステージング同様の変更に更新する処理」や「PRを作成する処理」を実行します。なお、1つ目の処理をmainブランチに反映しても本番環境に反映されることはありません(ターゲットとなるreleaseブランチには変更が反映されていないため)。 mainブランチ反映後、ステージング環境のArgo CDが差分を検知しデプロイします。ステージング環境の動作確認に問題がなければ、GitHub Actionsで自動作成されたreleaseブランチ向け本番リリースPRをマージします。releaseブランチ反映後、本番環境のArgo CDが差分を検知しデプロイします。 ちなみに、本番環境にもImage Updaterをインストールしてステージング環境と同様の仕組みを構築できますが、以下の懸念点があるため私達は本番環境への導入は見送りました。 mainとreleaseブランチを独立して運用することで、ステージング環境で動作確認したイメージと同じものをデプロイする保証がなくなる 私達のチームではステージング環境と本番環境の差分をなるべく減らして運用しています。安定したリリースフローを構築するためには、ステージング環境で動作確認したバージョンを確実にリリースする必要があると考えています。このため、「releaseブランチはmainブランチを追従する」というブランチ戦略を崩す選択はできないのです。 なお、ステージング環境で動作確認したイメージが本番環境のECRに存在することを保証するため、GitHub Actionsでイメージタグを検証する処理を追加しています。このアクションは本番リリースPRが作成されたタイミングで発動し、失敗するとPRをマージできない仕組みになっています。 Image Updaterの考慮すべき点と対応 Image UpdaterとGitHub Actionsを組み合わせることで自動リリースPRが作成されますが、運用してみると工夫しなくてはいけない問題が出てきました。 当初、私達は下図のように自前管理している全てのイメージをImage Updaterの監視対象としていましたが、以下のような不都合が生じました。 Image Updaterの監視サイクルによって変更を検知しないイメージが出る場合もある 同じイメージにタグがついた場合、Image Updaterが検知しない まず、「Image Updaterの監視サイクルによって変更を検知しないイメージが出る場合もある」から説明します。 私達が管理しているCIでは各イメージを並列でビルドして、完了したイメージからECRにプッシュしています。 このため、Image UpdaterがECRの情報を取得するタイミングによっては、プッシュが完了したイメージもあればプッシュが完了していないイメージもあります。 Image Updaterは1つでもイメージを検知すればブランチを作成するため、自動作成されたPRを確認すると一部のイメージが更新されていないケースもありました。 時間の経過と共に全てのイメージのプッシュが完了するため、時間を空けると全てのイメージを更新するPRが作成されますが、複数のPRが存在することでオペレーションミスが発生することも考えられます。 次に「同じイメージにタグがついた場合、Image Updaterが検知しない」を説明します。 私達はCircleCIでSkaffoldを利用してDockerイメージをビルドしECRへプッシュしており、高速化のためブランチごとキャッシュを持っています。 自前管理しているイメージのうち一部は内容に変更がない場合もあり、ECRへプッシュする際にタグが既存のイメージに付与されることがあります。 Image Updaterには最新のイメージを検知するよう設定しているため、既存のイメージにタグ付けされた場合は検知されません。このケースは時間が解決する問題ではないため、イメージタグがバラバラになってしまいます(同じイメージを参照しているが保守性が失われる)。 そこで、上記2つの課題を解決するため、下図のようにアプリケーション本体のイメージだけを監視し、GitHub Actionsで他のイメージに同様のタグをつける処理を実装しました。 アプリケーションのイメージは最もビルドに時間がかかり、リリースのたびに内容が変更されるため新しいイメージとしてECRにプッシュされるため、単一の監視対象として適切でした。 自動作成されたPRを確認すると、最初のコミットはImage Updaterが検知したアプリケーション本体のイメージタグの更新です。 次のコミットは、GitHub Actionsで処理している 他のイメージタグをアプリケーション本体のイメージタグと統一させる 変更です。 最後のコミットは、GitHub Actionsで処理している 本番環境のタグ管理ファイルをステージングのタグ管理ファイルと同じ内容に書き換える 変更です。 なお、GitHub Actionsの処理は各プロダクトで共通しているので、差分をパラメータ化した共通アクションとすることで管理コストを抑えることができました。 共通化したアクションはこのような内容になります。 inputs : argocd-application : description : Argo CD Application Name required : true source-image : description : image watched by Image Updater required : true target-images-to-duplicate-image-tag : description : images apart from source-image. The format of item is `imageA,imageB,imageC` required : true runs : using : composite steps : - name : Update production file shell : bash run : | git config --global user.email "action@github.com" git config --global user.name "GitHub Action" STG_FILE="kubernetes/overlays/staging/${{ inputs.argocd-application }}.yaml" PRD_FILE="kubernetes/overlays/production/${{ inputs.argocd-application }}.yaml" # source-imageのタグを全イメージに反映する。 IMAGES=${{ inputs.target-images-to-duplicate-image-tag }} for image in ${IMAGES//,/ }; do grep -oE ".+${{ inputs.source-image }}:[0-9a-z\-]+$" $STG_FILE | sed "s/${{ inputs.source-image }}/$image/g" >> $STG_FILE done # ステージング用のイメージタグの変更を本番用のファイルにも反映させる sed -e 's/<ステージング環境のAWS Account>/<本番環境のAWS Account>/g' $STG_FILE > $PRD_FILE git add $STG_FILE git commit -m 'update image tags on staging' git add $PRD_FILE git commit -m 'update image tags on production' git push origin HEAD - name : Create PR to main branch uses : actions/github-script@v6 with : script : | const { repo, owner } = context.repo; const result = await github.rest.pulls.create({ title : 'イメージタグの更新' , owner, repo, head : '${{ github.ref_name }}' , base : 'main' , body : 'staging 環境と production 環境のイメージタグを更新する。\n - staging リリース: main ブランチにマージすると自動で staging 環境の Pod が入れ替わる。\n - production リリース: main から release ブランチの PR をマージすると自動で production 環境の Pod が入れ替わる。' }); 呼び出し側のアクションは以下のようになります。 on : push : branches : - 'image-updater-**' jobs : create-pr : runs-on : ubuntu-latest steps : - name : Checkout uses : actions/checkout@v3 - name : Checkout common actions repository uses : actions/checkout@v3 with : repository : <共通化リポジトリ> path : common token : <Personal Access Token> - name : Use the action in common actions repository uses : ./common/.github/actions/<共通アクション> with : argocd-application : <タグ管理ファイル名> source-image : <Image Updaterが監視するアプリケーションイメージ> target-images-to-duplicate-image-tag : <source-image以外のイメージ群> # The format of item is `imageA,imageB,imageC` ロールバック ロールバックについて、以前はスクリプトを実行してリリース前のタグを指定して再度デプロイしていました。 今回、デプロイのトリガーをPRで管理するようにしたため、本番リリースPRをリバートするだけでロールバック作業が完了します。 ロールバックが必要な状況下においては、素早くかつ正確な作業が求められるため、PRをリバートするだけの仕組みはとても理想的な形になりました。 また、アプリケーションとKubernetesの変更が1つのPRに混在するケースは稀なので、リバートするべきPRを選別する手間はほとんど発生しません。 なお、現在は一部プロダクトでArgo Rolloutsを導入し、デプロイ作業中に異常を検知したら自動ロールバックするといった仕組みを構築しました。 導入前後の比較 リアーキテクト前 リアーキテクト後 デプロイ時間 約8分 3分以内 CDに利用するツール CodePipeline, CodeBuild, Shell Script, Skaffold Argo CD, Argo CD Image Updater オペレーションミスの可能性 別環境へデプロイする可能性あり 別環境へデプロイする可能性なし 横展開のしやすさ × ◯ Argo CDを導入したことで様々な恩恵を受けましたが、中でも横展開しやすさが私達のプロダクトと非常にマッチしていると実感しました。 先ほど紹介したとおり、私達はシングルクラスタ・マルチテナント構成で運用しているため、一度Argo CDを導入すればApplicationリソースにプロダクトを追加するだけでCDの構築が完了します。私達の計測サービスは新規事業に関わる事が多い部署であり、0→1のサービス開発が多いため、ビジネススピードに対応できることは大きなメリットです。 また、各プロダクトごと異なっていたリリース手順を共通化できたことは、管理コストを抑えることに繋がります。複数のプロダクトを管理する状況下においては、マルチテナントとArgo CDの相性の良さを実感しました。 まとめ 今回、Argo CDを導入するにあたり、GitHubリポジトリやブランチ戦略、Argo CDのPropject設計、リリースタグ更新の仕組みなど、様々な設計を検討する必要がありました。試行錯誤しながら私達のチーム状況に合わせたCDリアーキテクトが実現できたと思います。 今回紹介したデプロイフローの中で特徴的なのが、Image UpdaterとGitHub Actionsを組み合わせた自動PR作成の仕組みになります。 実際に運用して感じたメリットは、仕組みを横展開しやすいことです。マルチテナントで複数のプロダクトを運用している私達は、Image Updaterで全プロダクトのイメージを監視し、リリースPRを共通アクションで作成することは大きなメリットだと実感しています。 一方でデメリットは、複数のイメージを管理する場合、Image Updaterの動作に合わせて処理をカスタマイズする必要があることです。このため、複数プロダクトを運用しない環境においては、アプリリポジトリのCIでタグを更新する仕組みを構築した方が管理しやすいかもしれません。 Image Updaterを活用するメリット・デメリットを考慮して、プロダクトに合ったタグ更新の仕組みを構築するといいと思います。 終わりに 計測プラットフォーム開発本部では、今後も ZOZOFIT 等の新しいサービスのローンチを予定しています。更にスピード感を持った開発が求められますが、このような課題に対して楽しんで取り組み、サービスを一緒に盛り上げていける方を募集しています。少しでもご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co
アバター
はじめに ZOZOTOWN開発本部 ZOZOTOWNアプリ部 Android2ブロックの高橋です。 ZOZOTOWN Androidチームでは、リファクタリングやビルド速度改善の取り組みを継続的に行なっています。本記事では、それらの取り組みの効果を可視化するために導入した、コードメトリクスやビルド時間計測の方法について紹介します。 はじめに ZOZOTOWN Androidチームにおけるリファクタリングやビルド速度改善の取り組み コードメトリクスの計測 メトリクス Cyclomatic Complexity(循環的複雑度) LOC(ファイルのコード行数) Author数 計測方法 Cyclomatic Complexityの計測方法 Java Kotlin LOCの計測方法 Author数の計測方法 ビルド時間の計測 計測方法 計測結果の可視化 コードメトリクスの計測結果 計測結果のパース 内製ダッシュボードでの表示 データポータルでの表示 ビルド時間の計測結果 計測結果のパース Gradle Profilerが出力するHTML データポータルでの表示 まとめ 最後に ZOZOTOWN Androidチームにおけるリファクタリングやビルド速度改善の取り組み ZOZOTOWN Androidチームでは以前から、長いビルド時間や保守性の低いコードによって、チームの生産性が低下していることが問題となっていました。 そこで、保守性の高いコードを実現するためのリファクタリングや、ビルドの高速化に取り組んできました。しかし、それらの取り組みと並行して新機能の実装や既存機能の改修なども行なわれていたため、実際の改善度合いを把握することが難しい状況になっていました。 上記のような状況を鑑み、リファクタリングの効果・進捗とビルド時間を計測できる仕組みを検討し、導入しました。 コードメトリクスの計測 リファクタリングの効果・進捗を管理するための方法として、コードメトリクス計測の仕組みを導入しました。 メトリクス 計測するメトリクスは下記の目的に対応するものを選定しました。 リファクタリングの効果が高いファイルの検出 リファクタリングの進捗管理 チームのリファクタリングへの意識向上 属人化しているコードの把握 コードメトリクス計測の導入目的に対応するメトリクスを検討した結果、下記のメトリクスを計測することになりました。 メトリクス 説明 Cyclomatic Complexity(循環的複雑度) メソッド単位でのコードの複雑度 LOC ファイルの行数 Author数 ファイルに対して変更を加えたメンバーの数 メトリクスの検討段階では、上記の他にも「構造複雑度」や「他ファイルからの被参照数」なども有効なメトリクスとして候補に挙がりました。しかし、それらのメトリクスは既存のツールでの計測が難しい、あるいはメトリクスそのものの理解が難しいなどの問題がありました。そこで、比較的スムーズに導入可能かつ理解が容易な「Cyclomatic Complexity」「LOC」「Author数」から計測を始めました。 Cyclomatic Complexity(循環的複雑度) Cyclomatic Complexityは、メソッドの複雑度を示すメトリクスです。大まかにはif文やfor文などの分岐やループによって数値が増えます。数値の目安には決められたものはありませんが、一般的には下表のように言われています。 数値 複雑度とバグの混入リスク 〜10 シンプルな構造でバグの混入のリスクは低い 11〜20 やや複雑で中程度のバグの混入リスクがある 21〜50 複雑でバグの混入リスクが高い 51〜 テスト不可能な状態でバグの混入リスクが非常に高い Cyclomatic Complexityを計測することで、バグの混入リスクが高いメソッドを検出できます。以上から、Cyclomatic Complexityは効果的なリファクタリングやリファクタリングの進捗管理に利用できると考え、計測対象としました。また、継続的にメトリクスを監視することで、チームのリファクタリングへの意識向上にも役立つと考えました。 LOC(ファイルのコード行数) LOCは1ファイルあたりのコード行数を示すメトリクスです。LOCはいくつかの種類があります。 名称 説明 physical LOC(物理LOC) 空行やコメントの行数を含む、テキストファイルとしての行数 logical LOC(論理LOC) 空行やコメントの行数を含まない、実際の処理が記述されている行数 ZOZOTOWN Androidチームでは、空行やコメントを除いた実際の処理部分のリファクタリングにメトリクスを活用するため、logical LOCを計測対象のメトリクスとしました。 LOCを定期的に計測することで、削除予定となっているファイルや既に巨大になっているファイルに対する変更(追加)を把握できます。以上から、LOCはCyclomatic Complexityと同様に効果的なリファクタリングやリファクタリングの進捗管理に利用できると考え、計測対象としました。また、チームのリファクタリングへの意識向上についてもCyclomatic Complexityと同様に、継続的なメトリクスの監視によって達成できると考えました。 Author数 Author数は、ファイルに変更を加えた人数を示すメトリクスです。ZOZOTOWN Androidチームでは全てのコードの変更に対してコードレビューを実施しています。しかし、Author数が1の場合、該当ファイルを直接変更した人が1人しかおらずコードが属人化している状態である可能性が示唆されます。 Author数を計測することで、属人化したコードの内、特に重要な処理が記述されたコードの詳細をチームで共有できます。コードの属人化を解消することで、チームメンバーの仕様・実装理解が促進され、生産性の向上が期待できると考えました。Author数は、コードではなくGitのコミットログを解析して計測するため、一般的なコードメトリクスの文脈とは異なります。しかし、Author数はコードメトリクス計測の目的である「属人化しているコードの把握」に対応する指標であるため、計測することを決定しました。 計測方法 各メトリクスはそれぞれ異なるツールを使用して計測しました。いずれのツールも、継続的なメトリクス計測を目的として、GitHub ActionsのWorkflowに組み込みました。 ここでは、各メトリクスの計測方法とGitHub Actionsへの組み込みについて紹介します。 Cyclomatic Complexityの計測方法 Cyclomatic Complexityの計測には、KotlinとJavaで異なるツールを使用しました。 Java Javaで記述されたコードのCyclomatic ComplexityはJava用の静的コード解析ツールである checkstyle/checkstyle を使用して計測しました。 checkstyleではCyclomatic Complexityのthreshold(許容最大値)がデフォルトでは3になっています。そこで、全てのメソッドのCyclomatic Complexityを検出するため、設定ファイルでthresholdを0に変更しました。 GitHub Actionsでcheckstyleを実行するJobは下記のようになります。 java-complexity : runs-on : ubuntu-latest steps : - uses : actions/checkout@v3 - name : Setup Java uses : actions/setup-java@v2 with : distribution : 'zulu' java-version : '11' - name : Install checkstyle run : curl -sSLO https://github.com/checkstyle/checkstyle/releases/download/checkstyle-10.1/checkstyle-10.1-all.jar - name : Run checkstyle run : find . -name "*.java" | xargs java -jar ./checkstyle-10.1-all.jar -f xml -c .github/checkstyle_rule.xml -o checkstyle_result.xml || true - name : Archive uses : actions/upload-artifact@v2 with : name : result path : checkstyle_result.xml このJobでは、checkstyleを実行し、出力結果を保存します。checkstyleは静的解析によって発見されたエラーの数がexitコードとなります。stepを正常終了させるため、ここではcheckstyleのexitコードを無視しています。出力結果はGitHub ActionsのArtifactsとして保存します。 レポートファイルは下記のようなXMLで出力されます。 <? xml version = "1.0" encoding = "UTF-8" ?> <checkstyle version = "10.1" > <file name = "/path/to/File.java" > <error line = "18" column = "5" severity = "error" message = "Cyclomatic Complexity is 1 (max allowed is 0)." source = "com.puppycrawl.tools.checkstyle.checks.metrics.CyclomaticComplexityCheck" /> <error line = "25" column = "5" severity = "error" message = "Cyclomatic Complexity is 1 (max allowed is 0)." source = "com.puppycrawl.tools.checkstyle.checks.metrics.CyclomaticComplexityCheck" /> </file> ... </checkstyle> file タグの name と error タグの line 、 column と message を見ることで、計測対象のファイルに含まれるメソッドのCyclomatic Complexityを確認できます。 Kotlin Kotlinで記述されたコードのCyclomatic Complexityは、Kotlin用の静的コード解析ツールである detekt/detekt というツールを使用して計測しました。detektはコマンドラインツールやGradle Pluginとして利用できます。 detektではCyclomatic Complexityのthresholdがデフォルトでは15になっています。そこで、checkstyleと同様に全てのメソッドのCyclomatic Complexityを検出するため、設定ファイルでthresholdを0に変更しました。 complexity : ComplexMethod : active : true threshold : 0 GitHub Actionsでdetektを実行するJobは下記のようになります。 kotlin-complexity : runs-on : ubuntu-latest steps : - uses : actions/checkout@v3 - name : Install detekt run : | curl -sSLO https://github.com/detekt/detekt/releases/download/v1.21.0/detekt-cli-1.21.0.zip unzip detekt-cli-1.21.0.zip - name : Run detekt run : ./detekt-cli-1.21.0/bin/detekt-cli -c .github/detekt-config.yml -r xml:detekt_result.xml || true - name : Archive uses : actions/upload-artifact@v2 with : name : result path : detekt_result.xml このJobでは、detektを実行し、出力結果を保存します。checkstyleと同様に、stepを正常終了させるため、exitコードを無視しています。出力結果はGitHub ActionsのArtifactsとして保存します。 レポートファイルは下記のようなXMLで出力されます。 <? xml version = "1.0" encoding = "UTF-8" ?> <checkstyle version = "4.3" > <file name = "/path/to/File1.kt" > <error line = "30" column = "9" severity = "warning" message = "The function foo appears to be too complex (1). Defined complexity threshold for methods is set to & apos ; 0 & apos ; " source = "detekt.ComplexMethod" /> </file> <file name = "/path/to/File2.kt" > <error line = "9" column = "27" severity = "warning" message = "The function bar appears to be too complex (2). Defined complexity threshold for methods is set to & apos ; 0 & apos ; " source = "detekt.ComplexMethod" /> </file> ... </checkstyle> detektもcheckstyleと同様に、 file タグと error タグを見ることで、計測対象のファイルに含まれるメソッドのCyclomatic Complexityを確認できます。 LOCの計測方法 LOCの計測はさまざまなプログラミング言語に対応したLOC計測ツールである、 AlDanial/cloc を使用しました。 GitHub Actionsでclocを実行するJobは下記のようになります。 lines-of-code : runs-on : ubuntu-latest steps : - uses : actions/checkout@v3 - name : Setup cloc run : sudo apt install cloc - name : Run cloc run : cloc ./ --by-file --exclude-dir=build --include-ext=java,kt --xml --out=cloc_result.xml - uses : actions/upload-artifact@v2 name : Archive with : name : result path : cloc_result.xml このJobでは、clocを実行し、出力結果を保存します。JavaとKotlin以外のファイルや、ビルド時に生成されるファイルは解析対象から除外するため、 exclude-dir と include-ext を設定しています。出力結果はLOCと同様に、GitHub ActionsのArtifactsとして保存します。 レポートファイルは下記のようなXMLで出力されます。 <? xml version = "1.0" encoding = "UTF-8" ?> <results> <header> <cloc_url> github.com/AlDanial/cloc </cloc_url> <cloc_version> 1.82 </cloc_version> ... </header> <files> <file name = "path/to/File1.java" blank = "493" comment = "275" code = "2315" language = "Java" /> <file name = "path/to/File2.java" blank = "262" comment = "213" code = "1841" language = "Java" /> <file name = "path/to/File3.java" blank = "210" comment = "117" code = "1646" language = "Java" /> ... </files> clocでは、 file タグを見ることで、空行の数とコメント行数、logical LOCを個別に確認できます。 Author数の計測方法 Author数の計測は iwata-n/git-analyze をベースとし、カスタマイズしたものを使用しました。git-analyzeはファイル毎のコミット数やAuthor数を計測するリポジトリマイニングのツールです。git-analyzeは対象となるプロジェクトの全てのファイルに対して計測処理が実行されます。そこで、計測対象のファイル拡張子を指定できるようカスタマイズしたものを作成し、JavaとKotlinファイルのみを計測対象としました。 GitHub Actionsでgit-analyzeを実行するJobは下記のようになります。 number-of-authors : runs-on : ubuntu-latest steps : - uses : actions/checkout@v3 with : fetch-depth : 0 - name : Run git-analyze run : | chmod u+x .github/git-analyze .github/git-analyze -branch=$TARGET_BRANCH -parse-file=git_analyze_result.json -ext=kt,java - name : Archive uses : actions/upload-artifact@v2 with : name : result path : git_analyze_result.json このJobでは、git-analyzeを実行し、出力結果を保存します。出力結果は、GitHub ActionsのArtifactsとして保存します。 レポートファイルは下記のようなJSONで出力されます。 [ { " Path ": " path/to/File.kt ", " Authors ": [ " Metrics Taro ", " Metrics Hanako " ] , " CommitHash ": [ " f068ff6311893bdbae010c9c43b25ee65f1ccb06 ", " ab70bc1da76f067a3f9eea97159280750d998941 " ] , " CreateBy ": " Metrics Taro " } , { " Path ": " path/to/File.kt ", " Authors ": [ " Metrics Taro " , ] , " CommitHash ": [ " f068ff6311893bdbae010c9c43b25ee65f1ccb06 " , ] , " CreateBy ": " Metrics Taro " } ] 任意のファイルのAuthor数は Authors 配列のサイズを調べることで確認できます。 ビルド時間の計測 ビルド速度改善の効果計測と予期しないビルド時間の悪化を検知するため、ビルド時間計測の仕組みを導入しました。 計測方法 コードメトリクスの計測と同様に、ビルド時間を計測する仕組みもGitHub ActionsのWorkflowに組み込みました。ただし、コードメトリクスとは異なる頻度で計測するため、コードメトリクス計測とは別のWorkflowを用意しました。 ビルド時間の計測には、 Square社が公開しているビルド時間計測に関する記事 を参考に、 gradle/gradle-profiler を使用しました。Gradle ProfilerはGradleを使用しているプロジェクトのビルドパフォーマンスを計測するツールです。Scenarioと呼ばれる設定を記述することで、ビルド時間やAndroid StudioのSync時間など、さまざまなパフォーマンスを計測できます。 Scenarioの設定はSquare社の記事と Android Developers を参考に、下記のようにしました。 build { tasks = [":app:assembleDebug"] gradle-args = ["--offline", "--no-build-cache"] show-build-cache-size = true warm-ups = 4 } このScenarioでは、ビルドキャッシュを使用しなかった場合のビルド時間を計測します。 gradle-args には、プロジェクトが依存しているライブラリの取得時間が計測結果に影響を及ぼすことを防ぐため、 --offline を設定しています。また、ビルドキャッシュによるビルド時間計測への影響を防ぐため、 --no-build-cache も設定しています。 GitHub ActionsでGradle Profilerを実行するJobは下記のようになります。 measure-build-time : runs-on : ubuntu-latest steps : - uses : actions/checkout@v3 - name : Copy CI gradle.properties run : mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name : Set up JDK 11 uses : actions/setup-java@v2 with : distribution : 'zulu' java-version : '11' - name : Prefetch Gradle Dependencies run : ./gradlew --no-daemon assembleDebug - name : Run gradle-profiler run : | curl -s "https://get.sdkman.io" | bash source "$HOME/.sdkman/bin/sdkman-init.sh" sdk install gradleprofiler 0.18.0 gradle-profiler --benchmark --scenario-file .github/performance.scenario build --gradle-user-home $HOME/.gradle - uses : actions/upload-artifact@v2 name : Archive with : name : result path : profile-out このJobではGradle Profilerのインストールと実行し出力結果の保存します。出力結果は、GitHub ActionsのArtifactsとして保存します。 レポートファイルはCSV形式で出力されます。また、後述するHTML形式のレポートも出力されます。 scenario build version Gradle 7.0.2 tasks :app:assembleDebug value total execution time warm-up build #1 267090 warm-up build #2 148813 warm-up build #3 135940 warm-up build #4 131386 measured build #1 131454 measured build #2 135238 measured build #3 141732 measured build #4 136364 measured build #5 139061 measured build #6 135794 measured build #7 138930 measured build #8 142453 measured build #9 141535 measured build #10 143400 各イテレーションでのビルド時間は、 total execution time 列で確認できます。 計測結果の可視化 コードメトリクスの計測結果 コードメトリクスは複数のツールを組み合わせて計測しているため、結果の一覧性がありません。そこで、計測結果を一覧で確認できるダッシュボードを作成しました。また、計測結果をBigQueryに保存し、GoogleデータポータルなどのBIツールでメトリクスの推移を継続的に監視できる仕組みを導入しました。 計測結果のパース ダッシュボードの作成とBigQueryへの計測結果の保存に際して、メトリクス計測ツールが出力するXMLやJSONファイルを1つのJSON Linesファイルに統合するスクリプトを作成しました。このスクリプトは各メトリクスの計測後にGitHub Actions上で実行されます。出力されるJSON Linesファイルは、GitHub ActionsのArtifactsとして保存されます。 作成したスクリプトによって出力されるJSONは下記のようになります。実際はJSON Linesで出力されますが、ここでは見やすさのためフォーマットしています。 { " path ": " path/to/File.kt ", " language ": " Kotlin ", " loc ": { " blank ": 12 , " comment ": 4 , " code ": 39 } , " methods ": [ { " line ": 17 , " complexity ": 1 } , { " line ": 24 , " complexity ": 1 } , ] , " numberOfCommits ": 4 , " numberOfAuthors ": 3 , " branch ": " code_metrics ", " commitHash ": " cca154177ed75807f716bc9594fa16cc9a8405da ", " date ": " 2022-08-19 20:51:11 Asia/Tokyo " } { " path ": " path/to/File2.java ", " language ": " Java ", " loc ": { " blank ": 12 , " comment ": 4 , " code ": 39 } , " methods ": [ { " line ": 9 , " complexity ": 1 } , { " line ": 18 , " complexity ": 10 } ] , " numberOfCommits ": 6 , " numberOfAuthors ": 2 , " branch ": " code_metrics ", " commitHash ": " cca154177ed75807f716bc9594fa16cc9a8405da ", " date ": " 2022-08-19 20:51:11 Asia/Tokyo " } ... 各Keyの説明は下表の通りです。 Key 説明 値の取得に使用するツール path 対象ファイルのパス cloc, checkstyle, detekt, git-analyze language 言語 計測結果のパースをするスクリプト loc LOC cloc loc.blank 空行の数 cloc loc.comment コメントの行数 cloc loc.code logical LOC cloc methods メソッドの情報を格納する配列 checkstyle, detekt methods.line 対象のメソッドが存在する行番号 checkstyle, detekt methods.complexity 対象のメソッドのCyclomatic Complexity checkstyle, detekt numberOfCommits コミット数 git-analyze numberOfAuthors Author数 git-analyze branch 計測を実施したブランチ名 GitHub Actions commitHash 計測時点のコミットのハッシュ値 GitHub Actions date メトリクス計測を実施した日付 計測結果のパースをするスクリプト 内製ダッシュボードでの表示 スクリプトによって1つのファイルに統合された計測データは、社内にホスティングされたダッシュボードで確認できます。統合された計測データをダッシュボードにアップロードすると、1回分の計測結果を一覧で確認できます。このダッシュボードによって、計測結果をBigQueryに保存しない場合でもコードメトリクスの確認が可能になります。 ダッシュボードには、各ファイルのパス、言語、LOCと各ファイルに含まれるメソッドの最大Cyclomatic Complexity、Author数、コミット数が表示されます。任意のファイルのコードメトリクスは、言語やフリーワード入力のフィルターによってアクセスできます。また、各メトリクスでのソートも可能です。 データポータルでの表示 コードメトリクスの計測は、GitHub Actionsのscheduleイベントトリガーによって定期的に実行できます。scheduleイベントトリガーによって計測されたコードメトリクスは、BigQueryに保存することで、その推移を確認できます。 BigQueryに保存されたコードメトリクスは、データポータルで可視化できます。下図はZOZOTOWN Androidチームで継続的にリファクタリングを行なっているファイルの、ある期間にリリースされたバージョン毎のlogical LOCの推移を表しています。 この図からは、1750行以上あったlogical LOCがリファクタリングの取り組みによって100行以上減ったことがわかります。 このように、コードメトリクスの計測結果の保存・表示にBigQueryとデータポータルを利用することで、リファクタリング状況の継続的な監視が可能になります。 ビルド時間の計測結果 ビルド時間の計測結果は、Gradle Profilerが出力するHTMLで確認できます。また、コードメトリクスの計測結果と同様にビルド時間の推移を確認するための仕組みとして、BigQueryとデータポータルを導入しました。 計測結果のパース ビルド時間の計測結果についても、計測結果をBigQueryに保存するため、Gradle Profilerが出力するCSVファイルをJSON Linesファイルへ変換するスクリプトを作成しました。 作成したスクリプトによって出力されるJSONは下記のようになります。 { " times ": [ 153358 , 148786 , 155962 , 168292 , 162758 , 173117 , 162664 , 160480 , 162743 , 166319 ] , " mean ": 161447.9 , " median ": 162703.5 , " min ": 148786 , " max ": 173117 , " branch ": " build_time ", " commitHash ": " cca154177ed75807f716bc9594fa16cc9a8405da ", " date ": " 2022-08-24 01:30:52 Asia/Tokyo " } 各Keyの説明は下表の通りです。 Key 説明 値の取得に使用するツール times ビルド時間の計測結果(ms)の配列 Gradle Profiler mean 計測結果の平均値(ms) Gradle Profiler median 計測結果の中央値(ms) Gradle Profiler min 計測結果の最小値(ms) Gradle Profiler max 計測結果の最大値(ms) Gradle Profiler branch 計測を実施したブランチ名 GitHub Actions commitHash 計測時点のコミットのハッシュ値 GitHub Actions date ビルド時間計測を実施した日付 計測結果のパースをするスクリプト Gradle Profilerが出力するHTML Gradle Profileが出力するHTMLには、イテレーション毎のビルド時間の計測結果や、その平均値などが表示されます。 このHTMLファイルで計測されたビルド時間の詳細を確認できます。 データポータルでの表示 ビルド時間の計測もコードメトリクスの計測と同様に、GitHub Actionsのscheduleイベントトリガーによって定期的に実行できます。scheduleイベントトリガーによって計測されたビルド時間は、BigQueryに保存することで、その推移を確認できます。 データポータルで可視化した、ある期間にリリースされたバージョン毎のビルド時間の推移は下図の通りです。 この図からは、特定のバージョンからビルド時間が大幅に増加したことがわかります。このように、指標の推移を可視化することで、ある時点からの指標の大幅な変化を検知できます。上図の例では、データポータルでの計測結果の確認後、Pull Request単位でのビルド時間の変化を計測し、ビルド時間の悪化原因が含まれるPull Requestを特定できました。 まとめ 本投稿では、ZOZOTOWN Androidにおけるコードメトリクスとビルド時間計測の取り組みを紹介しました。コードメトリクスによって示される数値は、必ずしも実際のコードの良し悪しを表していません。しかし、効率的なリファクタリングやリファクタリングの進捗管理に利用できます。今後は、効果的なリファクタリングのための、より有効なコードメトリクスの導入を進めていきたいと考えています。また、チームのリファクタリングへの意識向上のための、効果的なコードメトリクスの運用方法についても検討しようと考えています。ビルド時間計測については、増分ビルドの時間計測などのより開発時に近いシナリオでの計測を進めていきます。 最後に ZOZOではAndroidエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください。 hrmos.co
アバター
はじめに こんにちは。SRE部の巣立( @ksudate )です。 ZOZOTOWNのマイクロサービス基盤では、GitHub Actionsを利用したCDパイプラインを構築しています。しかし、管理するマイクロサービスが増えるにつれて運用負荷が高まりつつありました。 本記事では、ZOZOTOWNのマイクロサービス基盤のCDパイプラインが抱える課題と、それらをFlux2でどのように解決したのかを紹介します。また、Flux2の導入にあたり工夫したポイントを紹介します。 目次 はじめに 目次 Flux2の導入背景 マイクロサービス基盤のCI/CDパイプラインが抱える課題とこれまでの対策 Flux2とは? Flux2によるGitOpsの実現 Flux2の導入で工夫したポイント Flux2の管理 GitRepositoryとKustomizationの管理 Flux2によるkustomize build 今後の展望 アプリケーションの自動更新 権限の最小化 さいごに Flux2の導入背景 マイクロサービス基盤のCI/CDパイプラインが抱える課題とこれまでの対策 マイクロサービス基盤では、アプリケーションのソースコードとKubernetesマニフェストを別々のリポジトリで管理しています。また、マニフェストを管理するリポジトリは複数のアプリケーションのマニフェストを含んでいます。 それぞれのリポジトリ上で稼働するCI/CDパイプラインは以下のような構成になっていました。 アプリケーションリポジトリではECRへImageがPushされます。PushされたImageをクラスタへ反映するにはインフラリポジトリのマニフェストを更新します。そのため、KubernetesクラスタへのデプロイはインフラリポジトリのGitHub Actionsから実行されます。GitHub ActionsによるCI/CDパイプラインで実行する kubectl diff と kubectl apply は全てのマイクロサービスに対して実行します。そのため、マイクロサービスが増えるとCI/CDが完了するまでの時間も増えていく構成となります。 CI/CDパイプラインではKubernetesクラスタへデプロイするJobだけでなくCloudFormationやTerraformを利用したデプロイを行うJobも同じワークフローで管理しています。そのため、CloudFormation・Terraformに対する変更の場合でも毎回 kubectl diff と kubectl apply が実行されます。 そこで paths-filter を利用することにしました。paths-filterを使うことでPull Requestに含まれる変更内容によって実行するJobを制御できるようになります。例えば、Kubernetesマニフェストに変更があった場合のみ kubectl diff と kubectl apply を実行するといったことが可能になります。 paths-filterによりCI/CDの高速化に成功したのですが、依然として kubectl diff と kubectl apply の長期化は解決しない状況でした。そのため、paths-filterを利用してマニフェストに変更があったマイクロサービスに対して kubectl diff と kubectl apply を実行する構成を考えました。しかし、この方法では条件分岐が増えることでワークフローが複雑になったり、マイクロサービスが増えるたびにJobが肥大化したりといくつか問題がありました。 そこで候補として上がったのが Flux2 です。 Flux2とは? Flux2はGitリポジトリで宣言された状態とクラスタの状態を同期するGitOpsツールの1つです。類似のOSSとして ArgoCD が挙げられます。Flux2では Kustomize で組み立てられたマニフェストをクラスタへ同期できます。そのため、Kustomizeを利用してマイクロサービスごとにマニフェストを生成し同期することが可能になります。 今回、ArgoCDではなくFlux2を採用したのは既にマイクロサービス基盤で利用している Flagger がFlux2と同じ Flux Project に所属しているため親和性が高いことを期待しました。Flaggerについては近日テックブログで公開する予定です。 Flux2によるGitOpsの実現 ここでは、Flux2のアーキテクチャと仕組みについて簡単に解説します。 Flux2はGitOps Toolkitと呼ばれるいくつかのコンポーネントにより動作します。 Source Controllerでは、Git、S3などからアーティファクトという形で外部ソースを取得します。アーティファクトはGitコミットのハッシュ値つまり、リビジョン番号を持ち、アーティファクトが更新されるとリビジョン番号も更新されます。 Kustomize Controllerでは、Source Controllerが取得したアーティファクトを元にクラスタへマニフェストを適用します。クラスタへ適用する間隔はCustom Resourceの設定により可能ですが、リビジョン番号が更新された場合は設定に関係なくクラスタへ適用されます。また、アーティファクトからマニフェストを生成する際には、 kustomize build を使用します。 その他Flux2が提供する機能について詳しくは公式ドキュメントをご覧ください。 fluxcd.io Flux2の同期の設定は、Custom Resourceにより設定できます。ここでは、GitRepositoryとKustomizationの2つのCustom Resourceについて簡単に紹介します。 GitRepositoryでは、Source ControllerがGitリポジトリからソースを取得する際の設定を書きます。これを各マイクロサービス毎に作成しています。同じNamespaceに複数のGitRepositoryを配置できるため、複数のマイクロサービスが配置されている場合でも問題なく動作します。 --- apiVersion : source.toolkit.fluxcd.io/v1beta2 kind : GitRepository metadata : name : flux-system namespace : flux-system spec : interval : 1m0s ref : branch : master secretRef : name : zozo-flux-system-secrets-202206141800 url : https://github.com/stefanprodan/podinfo KustomizationではKustomize Controllerがマニフェストをクラスタへ適用する際の設定を書きます。KustomizationもGitRepository同様にマイクロサービス毎に作成しています。なお、検証作業等のためにクラスタへの適用を一時的に停止したい場合は、 spec.suspend を true にします。 --- apiVersion : kustomize.toolkit.fluxcd.io/v1beta2 kind : Kustomization metadata : name : flux-system namespace : flux-system spec : interval : 1m0s path : ./k8s/dev/gitops-toolkit prune : false sourceRef : kind : GitRepository name : flux-system suspend : false ここで紹介したKustomizationは Kustomize で利用するKustomizationとは異なることに注意して下さい。 apiVersion: kustomize.config.k8s.io/v1beta1 apiVersion: kustomize.toolkit.fluxcd.io/v1beta2 これらのCustom Resourceにより spec.interval で指定した時間毎にクラスタへの同期が行われます。また、Flux CLIを使って即時の同期も可能です。 flux reconcile kustomization flux-system --with-source 実際にFlux2導入後のマイクロサービス基盤のCI/CDパイプラインは以下のような構成になります。これまでの git push を契機としたデプロイ手法であるCIOpsから、ソースをpullして差分を適用するGitOpsへとデプロイ手法が変わりました。変更後のCI/CDパイプラインでは、Pull Requestが作成されるとGitHub Actionsにより kubectl diff が実行されます。Pull Requestがmergeされた後、GitRepositoryの設定によりFlux2がリポジトリの更新を検知するとクラスタへ反映します。 これまでのCI/CDパイプラインではマニフェストの適用が完了するまでに10分から15分ほどかかっていました。しかし、マイクロサービス毎のデプロイが可能となりマニフェスト適用までの時間を1分以内に短縮できました。 Flux2の導入で工夫したポイント ここでは、マイクロサービス基盤にFlux2を導入した際に工夫したポイントをいくつか紹介します。 Flux2の管理 Flux2もKubernetes上で動作するアプリケーションの1つです。そのため、Flux2本体(Source ControllerやKustomize Controllerなど)をFlux2自身で管理可能です。 初回導入時のみ手動でインストールを行い、それ以降はFlux2自身で管理するようにしています。 GitRepositoryとKustomizationの管理 マイクロサービス基盤では、マイクロサービスごとにGitRepositoryとKustomizationを作成しています。この2つもFlux2により同期を行なっています。このマイクロサービスごとに作成された同期設定を管理するGitRepositoryとKustomizationは初回導入時のみ手動でインストールを行い、それ以降はFlux2で管理するようにしています。 Flux2によるkustomize build マイクロサービス基盤ではCIパイプラインで kubectl diff を実行します。そこで利用するマニフェストは kustomize build を実行し生成しています。生成されたマニフェストがFlux2のKustomize Controllerが適用するマニフェストと異なっていては困ります。そのため、いくつかのオプションを付与しFlux2が使用するKustomizeと同じ動作を実現しています。 kustomize build --load-restrictor=LoadRestrictionsNone --reorder=legacy . \ | kubectl diff --server-side --force-conflicts -f - --load-restrictor=LoadRestrictionsNone では、 kustomization.yaml が配置されたディレクトリ外からファイルを読み込むことを許可します。 --reorder=legacy では、生成したマニフェストを出力する順番に関するオプションです。 legacy では、NamespaceとClusterRole/RoleBindingが最初に出力され、CRの前にCRD、最後にWebhookが出力されます。 詳しくはFlux2のFAQをご覧ください。 fluxcd.io 今後の展望 アプリケーションの自動更新 Flux2では、ECRなどのImageリポジトリからImageを取得し、Gitリポジトリへコミットする機能があります。この機能を利用し、さらなるデプロイ時間の短縮に取り組んでいきます。 権限の最小化 Flux2のようにクラスタ上でソースとの差分を適用するというGitOps方式により、GitHub ActionsのようなCDパイプライン側に秘匿情報を渡す必要がなくなりました。しかし、現状のCIパイプラインでは秘匿情報が必要です。今後はCDパイプラインのみならずCIパイプラインの改善も取り組んでいきます。 さいごに Flux2を導入することで既存のCDパイプラインが抱えていた課題を解決できました。Flux2を活用することで改善できる課題はまだまだあります。 引き続き、快適な開発・運用を実現すべく改善していきます。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 hrmos.co
アバター
こんにちは、SRE部の廣瀬です。 本記事では、ZOZOTOWNでカートに商品を入れる際に使われているデータベース群の内、SQL Server(以降、カートDBと呼ぶ)にフォーカスします。ZOZOTOWNでは数年前から、人気の商品(以降、加熱商品と呼ぶ)が発売された際、カートDBがボトルネックとなる問題を抱えています。様々な負荷軽減の取り組みを通じて状況は劇的に改善されていますが、未だに完璧な課題解決には至っていません。 そこで今回は、加熱商品の発売イベントにおける負荷軽減の取り組みを振り返ります。また、直近の取り組みとして、SQL ServerのCDCを用いた新たな負荷軽減の検証内容をご紹介します。 背景 - カートDBのボトルネックについて 加熱商品の発売イベントに関する対策について、最初に言及した記事としては以下が挙げられます。この記事では人気の福袋商品を加熱商品として紹介していますが、それ以外にも加熱商品は様々な種類のものが存在します。 techblog.zozo.com 上記記事を参考に、加熱商品の発売イベントにおけるカートDBのボトルネックについてご説明します。 ZOZOTOWNのカート投入の仕様 ZOZOTOWNでは、「カートに入れる」ボタンを押したタイミングで在庫が確保されます。つまり、カートに入った商品はそのまま注文完了まで進めば、確実に購入できます。ECサイトによっては、「カートに入れる」ボタンを押したタイミングでは在庫が確保されません。代わりに、注文完了後に順次在庫の確保処理を実行して、在庫が確保できない場合はキャンセルのお詫びメールを送信する仕様になっています。ZOZOTOWNのカート投入の仕様は現実世界でのショッピングの体験を再現しており、個人的に好きな仕様の1つです。本仕様により、「カートに入れる」ボタンを押したタイミングで以下のようなクエリ(以降、在庫更新クエリと呼ぶ)がカートDBに対して実行されます。 update 在庫テーブル set 在庫数 = 在庫数 - 1 where PK = *** SQL Serverの論理リソース競合について SQL Serverでは、データを更新する際に様々なリソースに対して排他制御をかけます。様々な排他制御の内、本記事で言及する「行ロック」と「ページラッチ」について簡単に説明します。 「行ロック」は、行(レコード)に対して読み書きする際に獲得する必要のある論理的なリソースです。この仕組みによって「1つのレコードを一度に更新できるのは、1つのクエリだけ」といったルールを実現できます。 「ページラッチ」は、複数のレコードを格納している8KBの物理領域に対して読み書きする際に獲得する必要のある論理的なリソースです。基本的には「行ロック」も「ページラッチ」も、同一リソースへの書き込み(以降、writeと呼ぶ)は競合し、同一リソースへの読み取り(以降、readと呼ぶ)は競合しません。 表にまとめると以下の通りです。 write/write read/write read/read 行ロック 競合する 競合する 競合しない ページラッチ 競合する 競合する 競合しない 競合が発生すると、片方のクエリはもう片方のクエリが「行ロック」や「ページラッチ」を解放するまで待たされることになります。つまり、論理リソースの競合が発生するということは該当クエリの実行時間の遅延につながるということです。 なお、SQL Serverのロックについては以下の記事で詳しくまとめていますので、良かったらご覧ください。 qiita.com 加熱商品の発売イベントにおけるDB論理リソース競合 通常時は、様々な商品がカートに投入されている状況のため、複数の在庫更新クエリが同時に同一リソースへ更新要求を出すことはほとんどありません。したがって、「行ロック」も「ページラッチ」も大幅なクエリ遅延につながるような競合は発生しません。 しかし、加熱商品の発売イベントでは、特定の人気商品に対して在庫更新クエリが集中します。このような状況下では大量の「行ロック」および「ページラッチ」競合が発生し、クエリの実行時間の大幅な遅延やクエリタイムアウトエラー多発に繋がってしまいます。ワーストケースでは、クエリの遅延によりワーカースレッドが枯渇して、カートDB全体のスループットが著しく下がるという障害が発生することもあります。 このリソース競合を図示すると以下のようになります。 カートDBのボトルネックまとめ ここまでの内容をあらためてまとめます。 ZOZOTOWNでは「カートに入れる」ボタンを押したタイミングで在庫が確保され、内部的には在庫更新クエリが発行されている 在庫更新クエリを実行するためには「行ロック」および「ページラッチ」という論理リソースを獲得する必要がある 加熱商品の発売イベントでは、特定の商品にカート投入要求が集中し、DB内部で「行ロック」「ページラッチ」関連の論理リソース競合が多発する 論理リソース競合多発によってクエリの処理時間が遅延しタイムアウトエラー多発や、ワーカースレッド枯渇によるDB全体のスループット激減という障害につながることもある このように、CPU負荷やディスク負荷の高騰といった物理リソース起因ではなく、SQL Server内部で獲得する必要のある論理リソース競合がボトルネックである点が特徴的となっています。 次は、カートDBのボトルネックに対するこれまでの対応策を振り返っていきます。 カートDBボトルネック対策の歴史 1.在庫分割による排他制御の分散 こちらの記事 で紹介しているように、加熱商品の論理在庫を分割することで、在庫更新クエリによる排他制御を分散させる案です。 この対応のイメージ図は以下の通りです。 この案を2018年に実装して以降、2015年から3年連続で障害が発生していた福袋発売イベントを無障害で乗り切れています。一方で、以下のような課題も抱えていました。 運用負荷が大きく、年に1回の福袋発売イベント時だけ発動していた 効果は限定的で、分割するメリットが無いほどの少ない在庫数に対しては適用できない 他にもクエリチューニングを実施する等の様々な対策を施してきましたが、限界を迎えていました。具体的には、対策を入れて上昇していくDBの処理能力を、加熱商品の発売イベント時のトラフィックがさらに上回るようになっていきました。 そこでSQL Serverのレイヤだけで対応するのではなく、ワークロードを加味した別DBの選定等、課題の根本的な解決を目指すことになりました。成果の第一弾として、2021年にカート決済機能リプレイスのPhase1がリリースされましたので、そちらをご紹介します。 2.キューイングシステムの導入によるキャパシティコントロール これまでのカートDBでは、在庫更新クエリ数が増えれば増えるほど、カートDBへのリクエスト数も増える状況になっていました。そこで、カート決済機能リプレイスのPhase1という位置づけで、カートDBの前段にキューイングシステムを設置しました。これにより、在庫更新クエリが増えても、キューイングシステムの後段に位置するカートDBへの更新リクエスト数を一定に保つことが可能となりました。 キューイングシステムの概略図は以下の通りです。 より詳しい情報は下記のテックブログ達にまとまっております。よろしければご覧ください。 techblog.zozo.com techblog.zozo.com techblog.zozo.com aws.amazon.com この対応を入れたことで、下図のようにレイテンシ、エラー率の両面で劇的な効果を上げることができました。 ここまで、カートDBボトルネック対策の歴史を振り返りました。ワークアラウンドな対応にとどまっていた「在庫分割による排他制御の分散」と比べて、「キューイングシステムの導入によるキャパシティコントロール」は、あらゆる加熱商品に有効な負荷軽減の取り組みとなりました。しかし、ここまでの対策を実施しても論理リソース競合によるクエリタイムアウトの多発という障害の発生を完全に無くすことはできませんでした。 次項では、現状のシステム構成でも障害が発生してしまう要因を説明します。 現在のカートDBが抱えるボトルネック 現在のカートDBのボトルネックに関するワークロードの概略図を以下に示します。 在庫テーブルに対しては、常にwriteとreadの両方のリクエストが発生しています。writeは基本的に在庫更新クエリのみであり、キューイングシステムの導入によってキャパシティをコントロール可能な状態になっています。一方で、readは様々なリクエストで発生し、各リクエスト毎に同時実行数も異なります。例えば秒間10000リクエストのreadクエリもあれば、秒間10リクエストのreadクエリもあります。また、大半のreadはキューを経由しないため、アクセス増加に伴って秒間リクエスト数も増加していきます。 readとwriteはページラッチの競合が発生するため、writeのリクエスト数を一定に保ったとしてもreadの数が増えるほどページラッチ競合の発生リスクは増加していきます。したがって、readの増加によるreadとwriteのページラッチ競合が現在のカートDBのボトルネックということになります。なお、上述の内容は以下の記事に記載されている方法で調査を行い特定しました。よろしければご覧ください。 techblog.zozo.com techblog.zozo.com 次項では、現状のボトルネックを踏まえた負荷軽減の取り組みについてご紹介します。 直近でのカートDB負荷軽減の取り組み 現状のボトルネックを踏まえて、システムを以下の構成に変更することで論理リソース競合を軽減できるのではと考えました。 コンセプトとしてはシンプルで、タイムラグが許容されるreadはリードレプリカにアクセスを向けることで、readとwriteの競合発生の軽減を期待するというものです。リードレプリカへのデータ同期方法として、トランザクションレプリケーションとCDCという2パターンを検討しました。 トランザクションレプリケーション とは、SQL Serverが提供するレプリケーションの仕組みです。 CDC(Change Data Capture) とは、SQL Serverが提供する、テーブルに対するレコードの更新を記録できる機能のことです。 両者のおおまかな比較を以下の表にまとめます。 CDC 同一サーバー内でのレプリケーション サーバーの追加管理 〇(不要) 〇(不要) リードレプリカの レコード更新 ◎(1セッションからの直列な更新&リクエスト回数が圧縮可能) 〇(1セッションからの直列な更新) 運用 △(カラム追加時などに運用が発生) 〇(不要) 実装 △(データ同期処理など自作が必要) 〇(楽) リスク △(予期せぬトラブルが発生する懸念) 〇(トラブルの知見が社内に豊富) CDCの方がレコードの更新情報をリードレプリカに適用する回数を圧縮できるため、競合の軽減という観点では優れています。しかし、レプリケーションの方が社内で実績もあり、同一リソースへ同時にwriteが複数発生する現状と比較すると競合の軽減も見込めます。したがって、まずはレプリケーションを使ったリードレプリカ案で負荷試験を実施しました。 負荷試験では「在庫が減るスピード」に着目しました。理由は、在庫がはけ切った場合は更新が行われなくなり、readとwriteの競合も解消するためです。「いかに速く在庫が減る状況をつくれるか」を重要視しました。それ以外にも、加熱商品の発売イベントでは論理リソース競合が多発したりワーカースレッドが枯渇したりする実情を踏まえて、以下の4項目を負荷試験のキーメトリクスとしました。 在庫が減るスピード ロック競合の平均待ち時間 ページラッチ競合の平均待ち時間 ワーカースレッド獲得の平均待ち時間 リードレプリカに向けるクエリの決定方法 在庫テーブルには様々な種類のreadクエリが実行されています。種類が膨大なため全てをリードレプリカに向けるのは実装コストが高くなります。また、タイムラグが許容できないreadクエリはリードレプリカに向けることができません。したがって、加熱商品の発売イベントにおいて実行回数が多いreadクエリの中から、タイムラグが許容できる上位数種類のクエリをリードレプリカに向けました。 なお、各クエリの実行回数は こちらの仕組み を用いて取得しました。 負荷試験の実施方法 まずはコンセプトをDBレイヤ単体で検証するために、開発環境で JdbcRunner というツールを使って試験を実施しました。その後、プロダクション環境ではDBレイヤ単体の試験ではなく、アプリ側の負荷状況の変化もみるために、実際のユーザートラフィックを再現する形で試験を実施しました。 試験の実施には弊社のエンジニアが公開した「Gatling Operator」というOSSツールを使用しました。詳しくは以下のテックブログをご覧ください。 techblog.zozo.com レプリケーションを使ったreadとwriteの分離 レプリケーションを使ってリードレプリカを作成する案の負荷試験の結果を以下に示します。図は在庫が減るスピードを示しています。 図の通り、レプリケーション案では在庫の減るスピードが逆に鈍化してしまいました。また、数分間レベルの大幅な同期遅延も発生しました。ロック競合、ページラッチ競合、ワーカースレッド獲得の各種平均待ち時間も上昇していました。 レプリケーションでは、マスタ側の同一レコードに100回updateが行われた場合、レプリカ側のレコードに対しても100回updateが行われます。ただし、リードレプリカに更新を適用するのはレプリケーションに関連するエージェント1プロセスのみであるため、更新処理が直列化されます。これだけでも大幅なページラッチ競合の軽減を期待していましたが、そうなりませんでした。 CDC案もあるため原因の深掘りは行いませんでしたが、レプリケーションを使ったリードレプリカ作成によるreadとwriteの分離では、状況は改善できないと結論づけました。 CDCを使ったreadとwriteの分離 まず、CDC(Change Data Capture)という機能を簡単に説明します。この機能はDB単位で有効化した後、テーブル単位で個別に有効化します。有効化した後でテーブルに更新を加えると、サイドテーブルへ変更の履歴が保存されます。サイドテーブルをSELECTすると以下のような結果が得られます。 「_$operation」カラムはどのような更新が行われたかを示します。 1:delete 2:insert 3/4:update 3が古い値 4が新しい値 「$update_mask」カラムは、どのカラムが更新されたかをマスク値として持っています。例えば画像内の「0x04」は3列目が更新されたことを示しています。 なお、同期の仕組みを実装する際は、サイドテーブルを直接SELECTすることは行わず、 cdc.fn_cdc_get_net_changes_capture_instance を使います。このシステム関数を利用すると、指定した期間の最終的なカラムの値だけを取得できます。例えば、特定の期間に特定の1レコードへ1000回updateが行われたとしても、最新の値だけが適用すべき変更として取得できます。これにより、リードモデルとして作成したテーブルへの更新回数の圧縮が期待でき、リードモデル側のreadとwriteの競合発生を最小限に抑えることが期待できます。 詳しい実装例は本記事の末尾のAppendixで紹介します。 CDCを使ってリードレプリカを作成する案の負荷試験の結果を以下に示します。図は在庫が減るスピードを示しています。 図の通り、在庫が減るスピードは9倍速と劇的に改善されました。また、同期間隔10秒に対して遅延時間は最大でも約15秒と安定していました。キーメトリクスとしていた各種待ち時間も下図の通り劇的に低減しました。 リクエスト数が多いreadクエリをリードレプリカに向けることでreadとwriteの競合発生を低減できました。また、競合による待ち時間が低減したことで、各クエリの処理速度が向上して在庫が減るスピードも大幅に高速化できました。 以上の結果から、CDCを用いたリードレプリカ案は、現在カートDBが抱えているボトルネックに対して非常に有効な手法であると結論づけました。 Limitation 今回の負荷試験では、CDCを使ったreadとwriteの分離でボトルネックの劇的な改善がみられました。 しかし、制限もいくつか存在します。まず第一に、ワークロードの性質の変化に弱い点が挙げられます。秒間リクエスト数が少なかったreadクエリの突発的なスパイクによって、別の箇所へボトルネックが移動する懸念があります。 次に、同期遅延の発生を許容しなければならない点が挙げられます。結果整合性が許容できないクエリの場合は、論理リソース競合の一因になっているとしても、リードレプリカにreadクエリを向けることはできません。 このように、アプリケーションが持つ性質次第では本手法は使えない可能性もあります。ですが、プロダクション環境でのワークロードを模した今回の負荷試験では非常に良い結果を得られました。 まとめ 本記事ではZOZOTOWNが長年抱えている、加熱商品の発売イベントにおけるDBボトルネック起因の障害と、その対策の歴史を振り返りました。また、直近の負荷軽減の取り組みとして、SQL ServerのCDCを用いたリードレプリカの作成によるreadとwriteの分離案を紹介しました。 この方法によって、ボトルネックとなっていた論理リソース競合を劇的に改善させ、在庫が減るスピードも既存の9倍速となる結果を負荷試験で達成できました。諸事情によりプロダクション環境への導入は見送ることになりましたが、提案手法のコンセプトが有効であることを実証できました。 今後の展望 カートDBは未だにボトルネックを抱えている状態です。今後の対応としては以下のような案が考えられます。 課題解決に適したDBを選定してワークロードをオフロードする カートDBへの更新リクエストだけではなく、参照処理なども含めキューイングさせる 加熱商品の発売時は抽選制にするなどアプリ側の仕様を変えることでワークロードの性質も変える 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com Appendix - CDCを用いた同期の仕組みの実装 1.DB単位でのCDCの有効化 以下のクエリを実行します。デッドロック等で失敗する可能性があり、その際即時ロールバックが確実に行われるよう、オプションも付けておくと安心です。 set xact_abort on set lock_timeout 500 EXEC sys.sp_cdc_enable_db GO 2.テーブル単位でのCDCの有効化 テーブル単位の有効化は、ロックを獲得できれば基本的に瞬時に完了します。しかし1つめのテーブルに対してCDCを有効化するタイミングでは、関連jobの作成等が行われるため、完了まで数秒かかります。また、その間は該当テーブルへのクエリはブロックされます。したがって、CDCを初めてテーブルに設定する場合は、全くアクセスの無いダミーテーブルに対して設定する方が安全です。ダミーテーブルは例えば以下のようなスキーマで作ります。 CREATE TABLE [dbo].[dummy_table]( [c1] [ int ] IDENTITY( 1 , 1 ) NOT FOR REPLICATION NOT NULL , [c2] [ int ] NOT NULL , CONSTRAINT [PK_dummy_table] PRIMARY KEY CLUSTERED ([c1] ASC )) ON [PRIMARY] GO 次に、ダミーテーブルに対してCDCを有効化します。数秒かかりますが、アクセスが無いためブロッキングは起きないはずです。 EXEC sys.sp_cdc_enable_table @source_schema = N' dbo ' , @source_name = N' dummy_table ' , @role_name = null , @filegroup_name = N' primary ' , @supports_net_changes = 1 GO そのあと、本当にCDCを設定したいテーブルに対してCDCを有効化します。こちらはアクセスが有るテーブルのため、ブロッキング多発に備えてオプションを設定しておきます。 set xact_abort on set lock_timeout 500 EXEC sys.sp_cdc_enable_table @source_schema = N' dbo ' , @source_name = N' target_table ' , @role_name = null , @filegroup_name = N' primary ' , @supports_net_changes = 1 GO 3.CDCの設定変更 CDC設定による変更履歴データは、期限が切れると自動でクリーンアップされます。期限はデフォルトで4320分(3日後)になっていますが、今回は長すぎると判断し1440分(1日後)に変更しました。 また、クリーンアップ処理時の変更履歴データの1回あたりの削除レコード数をデフォルトの5000から3000に変更しました。理由は、クリーンアップ処理中のCDCサイドテーブルへのロックエスカレーション発生を防止するためです。ロックエスカレーションが発生すると、CDCサイドテーブルへのinsertがブロックされ、リードレプリカ側へのデータ同期遅延の懸念があります。 この設定は以下のクエリで実施しました。 set xact_abort on set lock_timeout 500 go sys.sp_cdc_change_job @job_type =  ' cleanup ' , @retention =  1440 , @threshold =  3000 4.CDCを用いた同期の仕組みづくり CDCを用いてリードレプリカに継続的に変更データを同期するためには、仕組みを自作する必要があります。そのためのテーブルを作成します。 --ウォーターマークの管理テーブル CREATE TABLE [dbo].[CDCWaterMarks] (     [ID] [bigint] IDENTITY( 1 ,  1 ) NOT NULL ,     [WaterMark] [binary]( 10 ) NULL ,     [ModifiedAt] [datetime2] NOT NULL ,     CONSTRAINT [PK_CDCWaterMarks] PRIMARY KEY CLUSTERED     (         [ID] ASC     ) ON [PRIMARY] ) ON [PRIMARY] --1行だけinsertして、あとは都度updateしていく INSERT INTO CDCWaterMarks VALUES ( NULL , sysdatetime()) --同期ログを保存するテーブル CREATE TABLE [dbo].[CDCSyncLogs] (     [ID] [bigint] IDENTITY( 1 ,  1 ) NOT NULL ,     [LogMessage] [ varchar ]( 4000 ) NOT NULL ,     [CreatedAt] [datetime2] NOT NULL CONSTRAINT [DF_CDCSyncLogs_CreatedAt] DEFAULT (sysdatetime()),     CONSTRAINT [PK_CDCSyncLogs] PRIMARY KEY CLUSTERED     (         [CreatedAt] ASC     ) ON [PRIMARY] ) ON [PRIMARY] --同期の遅延時間を計測するためのテーブル CREATE TABLE [dbo].[CDCSyncLatencies] (     [ID] [bigint] IDENTITY( 1 ,  1 ) NOT NULL ,     [SyncLatency] INT NOT NULL ,     [CreatedAt] [datetime2] NOT NULL CONSTRAINT [DF_CDCSyncLatencies_CreatedAt] DEFAULT (sysdatetime()),     CONSTRAINT [PK_CDCSyncLatencies] PRIMARY KEY CLUSTERED     (         [CreatedAt] ASC     ) ON [PRIMARY] ) ON [PRIMARY] 次に、リードレプリカとなるテーブルを元テーブルと同じスキーマで作成します。 create table [dbo].[target_table_read_replica] ( [id] [bigint] ... [column1] [ int ] ... ... ) 続いて、リードレプリカに全件INSERTします。 insert into target_table_read_replica select * from target_table order by id その後、必要に応じて元テーブルと同じインデックスをリードレプリカにも作成します。次に、リードレプリカへの同期ジョブを作成します。以下のようなクエリをエージェントジョブのステップに登録して実行します。 以下のクエリを使うと10秒ごとにリードレプリカに対して変更履歴を同期します。 set transaction isolation level read uncommitted set lock_timeout  1000 set nocount on   declare @watermark varbinary( 10 ) declare @first_lsn varbinary( 10 ) declare @end_lsn varbinary( 10 ) declare @end_time  datetime declare @message  varchar ( 4000 ) declare @merge_count  int declare @delete_count  int   while ( 0 = 0 ) begin      begin  try           select @watermark = WaterMark from CDCWaterMarks with (nolock)            --@first_lsnをセット          if  @watermark  is not   null          begin              --管理テーブルに入っているlsnをインクリメント              set  @first_lsn = sys.fn_cdc_increment_lsn(@watermark)          end          else          begin              --初回は最小のlsnを指定              set  @first_lsn = sys.fn_cdc_get_min_lsn( ' dbo_target_table ' )          end            --@end_lsnをセット          set  @end_time = getdate()          set  @end_lsn = sys.fn_cdc_map_time_to_lsn( ' largest less than or equal ' , @end_time)            set xact_abort on          begin tran                --リードレプリカへ更新を反映(ins/upd)             merge target_table_read_replica as replica_table              using (select * from cdc.fn_cdc_get_net_changes_dbo_target_table(@first_lsn, @end_lsn,  ' all ' ) where __$operation in ( 2 , 4 )) AS master_table  --2:ins / 4:upd              on replica_table.id = master_table.id              when matched              then update              set                  replica_table.column1 = master_table.column1                 ,replica_table...              when not matched by target              then insert (id,column1,...)                   values (master_table.id,master_table.column1,...)             ;                set  @merge_count = @@rowcount                --リードレプリカへ更新を反映(del)              delete from target_table_read_replica              from cdc.fn_cdc_get_net_changes_dbo_target_table(@first_lsn, @end_lsn,  ' all ' ) as master_table              where                 master_table.id = target_table_read_replica.id              and master_table.__$operation  =  1                set  @delete_count = @@rowcount                --watermarkの更新             update CDCWaterMarks set WaterMark = @end_lsn                set  @message =  ' success : first_lsn: '  + CONVERT ( varchar ( 100 ), @first_lsn, 1 ) +  ' / last_lsn: '  + CONVERT ( varchar ( 100 ), @end_lsn, 1 ) +  ' / merge_count: '  + cast (@merge_count  AS varchar ( 100 )) +  ' / delete_count: '  + cast (@delete_count  AS varchar ( 100 ))               insert into CDCSyncLogs (LogMessage) values (@message)            commit tran                        --10秒ごとに同期させるためwait         waitfor delay  ' 00:00:10 '      end  try      begin  catch          if  @@trancount <>  0          begin              rollback          end            set  @message =  ' error : '  + cast (error_number() as varchar ( 100 ))+  ' : '  + error_message() +  ' : first_lsn: '  + CONVERT ( varchar ( 100 ), @first_lsn, 1 ) +  ' / last_lsn: '  + CONVERT ( varchar ( 100 ), @end_lsn, 1 )           insert into CDCSyncLogs (LogMessage) values (@message)            --10秒ごとに同期させるためwait         waitfor delay  ' 00:00:10 '      end  catch end 遅延時間を計測するために、以下のクエリ使ったエージェントジョブを別途作成して実行します。1秒ごとに同期の遅延時間を専用テーブルに格納していきます。 set transaction isolation level read uncommitted set lock_timeout 1000 set nocount on while ( 1 = 1 ) begin insert into CDCSyncLatencies (SyncLatency) select datediff(second, sys.fn_cdc_map_lsn_to_time(WaterMark), getdate()) as cdc_sync_delay_sec from CDCWaterMarks with (nolock) waitfor delay ' 00:00:01 ' end 5.CDCの無効化 無効化したい場合は、上記手順を逆にたどればOKです。まず同期用のエージェントジョブを停止します。次に、テーブル単位でCDCを無効化します。 set xact_abort on set lock_timeout 500 EXEC sys.sp_cdc_disable_table @source_schema = N' dbo ' , @source_name = N' target_table ' , @capture_instance = N' dbo_target_table ' GO EXEC sys.sp_cdc_disable_table @source_schema = N' dbo ' , @source_name = N' dummy_table ' , @capture_instance = N' dbo_dummy_table ' GO 最後にDB単位でCDCを無効化して完了です。 set xact_abort on set lock_timeout 500 EXEC sys.sp_cdc_disable_db GO
アバター
はじめに こんにちは、フロントエンド部WEARiOSブロックの西山です。 iOS 13から登場したCompositional Layoutsを使うことで、App Storeのような複雑なUIが簡単に実現できるようになりました。 登場前は、 UICollectionView in UICollectionView または、 UIStackView + UIScrollView in UICollectionView で頑張って実現していたところを UICollectionView 1つで実現できます。 一方で、登場前の方法では簡単に出来ていたカスタマイズをCompositional Layoutsで実現しようとすると難しくなるケースが存在しました。その1つに、横スクロールするセル全体にドロップシャドウを付ける方法が挙げられます。 WEARには次のようなUIが存在します。 このキャプチャ画像では少し分かりづらいかもしれませんが、セル全体にドロップシャドウが付いています。このUIをCompositional Layoutsで実現するのが一筋縄ではいかなかったので、WEARでの解決方法を紹介します。 環境 Xcode 13.4.1 Swift 5.6.1 一筋縄ではいかなかった理由 1. セルを覆うクラスが公開されていない Compositional Layouts登場前のWEARでは、 UIStackView + UIScrollView in UICollectionView で実現していました。 UIStackView をラップするような形でシャドウ用のViewを用意する方法を取っていたので比較的簡単に実現できていました。 Compositional Layoutsでも似たような方法を取れれば簡単に実現できますが、残念ながらCompositional Layoutsのセルをラップするクラスは公開されていませんでした。 2. セルにシャドウを付けると繋ぎ目からはみ出る セルのラップクラスにシャドウを付けることは適わなそうなので、セル1つ1つにシャドウを付ける方法を取りました。しかし、この方法ではセルとセルの繋ぎ目から前後どちらかのシャドウがはみ出してしまいうまくいきません。 解決方法 mask layer を利用する セルにシャドウを付ける方法を取りつつ mask layer を利用し、はみ出す部分を隠します。 mask layer の簡単なおさらいですが、 View の切り抜きや穴を開ける方法で語られることが多いと思います。 class ViewController : UIViewController { override func viewDidLoad () { super .viewDidLoad() view.backgroundColor = .darkGray // 丸のlayerをセンターに置く let maskLayer = CAShapeLayer() maskLayer.frame = view.bounds let width : CGFloat = 200 let height : CGFloat = 200 let point = CGPoint(x : view.center.x - width / 2 , y : view.center.y - height / 2 ) let rect = CGRect(origin : point , size : . init (width : width , height : height )) let path = UIBezierPath(roundedRect : rect , cornerRadius : width / 2 ) maskLayer.path = path.cgPath view.layer.mask = maskLayer } } 背景色 darkGray の View に丸の mask layer をセンターに置いたサンプルコードです。上記コードを実行すると次のようになります。 要するに mask layer と重なる部分が表示されるようになります。 mask layer を使用するための Position やりたいことは、初めのセルの右側、中間のセルの左右、最後のセルの左側を隠すことです。 そのため、どのポジションにいるのか判断できるように型を用意しています。 enum Position { case first case middle case last(isSingle : Bool ) } extension Position { init (index : Int , itemCount : Int ) { switch (index, itemCount) { case let (index, count) where (index + 1 ) == count : self = .last(isSingle : index == 0 ) case ( 0 , _) : self = .first default : self = .middle } } } ポジションに合わせて mask layer を定義します。 let width = bounds.width let height = bounds.height let maskSpace : CGFloat = 50 // 適切な値を指定 var maskLayer : CALayer? = CALayer() maskLayer?.backgroundColor = UIColor.black.cgColor switch position { case .first : // 上、左、下にシャドウが表示されるようlayerを被せる maskLayer?.frame = . init (x : - maskSpace, y : - maskSpace, width : width + maskSpace, height : height + maskSpace * 2 ) case .middle : // 上、下にシャドウが表示されるようlayerを被せる maskLayer?.frame = . init (x : 0 , y : - maskSpace, width : width , height : height + maskSpace * 2 ) case let .last(isSingle) : if isSingle { // 隠したくないのでlayerを削除 maskLayer = nil } else { // 上、下、右にシャドウが表示されるようlayerを被せる maskLayer?.frame = . init (x : 0 , y : - maskSpace, width : width + maskSpace, height : height + maskSpace * 2 ) } } WEARでは再利用できるよう ShadowView を用意しています。 class ShadowView : UIView { private var position : Position? override func draw (_ rect : CGRect ) { super .draw(rect) drawShadow() } func updateShadow ( for position : Position ) { self .position = position setNeedsDisplay() } private func drawShadow () { guard let position = position else { return } let width = bounds.width let height = bounds.height let maskSpace : CGFloat = 50 var maskLayer : CALayer? = CALayer() maskLayer?.backgroundColor = UIColor.black.cgColor switch position { case .first : // mask layerのコード省略 ... layer.shadowOffset = .zero layer.shadowColor = UIColor.black.cgColor layer.shadowRadius = 10.0 layer.shadowOpacity = 0.6 layer.mask = maskLayer } } この方法を取ることでセル全体へのシャドウをつけることが出来ました。 shadowPath で更に調整 セル全体へシャドウを適用した時点では、若干繋ぎ目が離れているところが気になります。 shadowPath を利用することで、もう少し繋がっている様に見せられます。 View よりも少し広めに shadowPath を引くことで繋ぎ目を可能な限り消します。 ShadowView の drawShadow に手を加えます。 // mask layerのコードを省略しています private func drawShadow () { let width = bounds.width let height = bounds.height ... let shadowSpace : CGFloat = 10 // 適切な値を指定 let shadowPath : UIBezierPath switch position { case .first : shadowPath = . init (rect : . init (x : 0 , y : 0 , width : width + shadowSpace, height : height )) ... case .middle : shadowPath = . init (rect : . init (x : - shadowSpace, y : 0 , width : width + shadowSpace * 2 , height : height )) ... case let .last(isSingle) : if isSingle { shadowPath = . init (rect : . init (x : 0 , y : 0 , width : width , height : height )) ... } else { shadowPath = . init (rect : . init (x : - shadowSpace, y : 0 , width : width + shadowSpace, height : height )) ... } } layer.shadowPath = shadowPath.cgPath ... 完璧とまではいきませんが、 shadowPath を利用することでより良くなったのではないでしょうか。 サンプルコードはわかりやすいようにシャドウを濃くしていましたが、実際はもう少し薄いので馴染んで見えます。 さいごに Compositional Layoutsでセル全体にドロップシャドウをつける方法を紹介しました。同じ様な悩みを抱えている方の参考になれば幸いです。以前は割と簡単だった実装もCompositional Layoutsでは難しいケースが他にもあるので、今後のアップデートで更にカスタマイズしやすくなることを期待しています。 WEARではiOSエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください。 hrmos.co
アバター
こんにちは、技術本部SRE部ZOZO-SREブロックの 鈴木 です。普段はSREエンジニアとしてZOZOTOWNの裏で動いているオンプレミスとクラウドの構築・運用・保守に携わっています。 ZOZOTOWNのインフラは大半がIaC化されていますが昔からあるリソースに関してはその限りではありません。弊社で導入しているAkamaiもIaC化されていないリソースの1つでしたが、頻繁な更新などによって重複した設定が入っている箇所がある等、長く運用しているとどうしても陥ってしまう沼にハマっていました。本記事ではこの沼から抜け出そうと部分的にもIaC化を導入して問題を解決したことを、Akamaiのネットワークリストを例に紹介します。同様の問題を抱えていた方の参考になれば幸いです。 目次 目次 はじめに 課題 AkamaiのIaC化 既存環境のTerraform化 1. Terraformの設定 2. ネットワークリストのリソースIDを取得 3. インポート 4. コード化 コード化によって まとめ 最後に はじめに まずはじめに、本記事で登場するキーワードであるInfrastructure as Code(以下IaC)について簡単に説明します。 IaCとは、インフラ構成をコード化して、コードの内容を自動でプロビジョニングすることです。コード化することによってソフトウェア開発における便利なツールが使えるようになり、様々な恩恵を受けられます。 ZOZOTOWNにおいては大半のリソースがIaCによって管理されているものの、昔からあるリソース、そもそもIaC化にかかるコストが効果に見合わないものはIaC化されないという柔軟な運用になっています。弊社では数多くのSaaSを導入しており、今回テーマとなるAkamaiも費用対効果が見合わないものとして導入当時にIaC化されていなかったリソースです。 Akamaiは弊社で利用しているCDNサービスであり、様々な用途で利用されています。以下のブログでもAkamaiを利用した事例について書かれています。 techblog.zozo.com techblog.zozo.com Akamaiをいろいろなところで使うに従って設定情報は増えていきます。Akamaiの設定のうち、頻繁に変更するものとして「ネットワークリスト」があります。ネットワークリストはIPおよびGEOを単位としたリストを作り、ネットワークファイアウォールのブロック制御等を行うことができます。弊社では特定のIPのみ通信を通したいときなど、用意しておいたネットワークリストにIPを追加することで通信を許可できるように設定しています。 課題 Akamaiをしばらく運用するに従い、いくつか課題点がでてきました。 ネットワークリストの更新はAkamaiのコンソールから行っていました。ネットワークリストの更新が頻繁になるにつれ、このIPはなんのために追加したものなのか、いつ誰が追加したものなのかを追うのが困難になっていました。Akamaiのコンソール上にも設定変更の履歴が表示されるものの変更内容の詳細までは表示されません。それぞれのネットワークリストにはコメントをつけられるものの文字数制限があり、必要な情報すべてを書けませんでした。リストの内容の検索ができなかったため、内容の重複するようなリストがいくつも作成されるなど、次第に積み重ねによる使いにくさがどんどん増えていきました。 「設定の内容が可視化できていない」ことが私達の課題点でした。 これらの解決策としてパラメータシートのような資料を作り、設定を記録していくことが考えられますがメンテナンス性に難があります。そこで私達はAkamaiの設定情報をIaC化することで解決を図りました。今回のIaC化はひとまずネットワークリストのみに対象を絞ります。目標としては「ネットワークリストの設定変更の履歴を追える」「CI/CDを用いたデプロイが可能」の状態を目指し、AkamaiのすべてをIaCを目指すところまでは行わないことに決めました。 AkamaiのIaC化 AkamaiをIaC化する上で使えるツールとしては「Akamai API」「Akamai CLI」「Terraform」の3つがあります。 Akamai APIを用いることでAkamaiの提供する機能をAPIで操作できます。APIを直接利用して操作するため、コーディングが必要となりますが自由度が高く複雑なインフラも簡単に構築できます。 www.akamai.com Akamai CLIはAkamaiを操作するためのCLIツールです。GUIを使わずにAkamaiを操作でき、工夫次第でIaC化などにも有用なツールです。 www.akamai.com Terraformは代表的なIaCツールであり、Akamai社がTerraformの Providerを提供 しているため、ほとんどの設定がTerraform上で管理可能です。代表的なIaCツールであり、IaC化したコードのデプロイ等の機能だけでなく、インポートや実際の環境との差分検知など便利な機能が多数あります。 Akamai CLIを用いてTerraformの設定ファイルを生成も可能です。初期構築の際など複雑な設定をする際にはこちらの機能を利用するとお手軽にIaC化を進められます。弊社でもWEARチームがAkamai CLIからTerraformの設定ファイルを作成して適用する流れをProperty Managerの設定の際に利用しています。 techblog.zozo.com それぞれのツールについて簡単に比較したものが以下になります。 観点 Akamai API Akamai CLI Terraform 柔軟な設定 ◎ ◎ ○ 既存設定のインポート △ △ ◎ 導入スピード △ △ ○ 各種機能が揃っており、代表的なIaCツールでありメンバーも特別な学習をすることなく利用できる点に魅力を感じ、今回はTerraformを用いてIaC化を行いました。特にすでにあるAkamaiの設定を手軽にインポートできる機能はAkamaiをすでに長く利用していた私達の求めていたものでした。 既存環境のTerraform化 既存のAkamaiのネットワークリストをTerraformを用いてIaC化する方法を解説します。流れとしては以下になります。 Terraformの設定 ネットワークリストのリソースIDを取得 インポート コード化 1. Terraformの設定 TerraformからAkamaiの設定を操作できるように Authenticate the Akamai Provider に従い認証をします。クレデンシャル情報を作成し .edgerc を用意する方式でまずは行いました。 ❯ cat ~/.edgerc [ default ] host = akab-....luna.akamaiapis.net client_secret = pc... access_token = akab-... client_token = akab-... 2. ネットワークリストのリソースIDを取得 実際の環境で動いているネットワークリストのリソースIDを取得します。 terraform { required_providers { akamai = { source = "akamai/akamai" version = "1.10.0" } } } provider "akamai" { edgerc = "~/.edgerc" config_section = "default" } data "akamai_networklist_network_lists" "network_lists" { } output "network_lists_list" { value = data.akamai_networklist_network_lists.network_lists. list } ❯ terraform init ... ❯ terraform.sh plan Changes to Outputs: + network_lists_list = [ + " 1000_ZOZOOFFICE " , + " 2000_ZOZOSERVICEIPS " , ... 出力されたIDが実際の環境ですでに動いているリソースのIDであり、設定をインポートする際に利用します。 3. インポート すでに作られているネットワークリスト情報を入れる先として空のリソースを作成し、インポートコマンドを使うことでTerraformの管理下にリソースを置きます。 resource "akamai_networklist_network_list" "zozo_office_ip_list" {} ❯ terraform import akamai_networklist_network_list.api_zozo_com_zozo_platform_natgateway 1000_ZOZOOFFICE インポートした内容は state show コマンドを用いることで確認できます。 ❯ terraform state show akamai_networklist_network_list.zozo_office_ip_list # akamai_networklist_network_list.zozo_office_ip_list: resource " akamai_networklist_network_list " " zozo_office_ip_list " { description = " Office IP List " id = " 1000_ZOZOOFFICE " list = [ " a.a.a.a " , " b.b.b.b " , ... ] mode = " REPLACE " name = " ZOZO_OFIICE " network_list_id = " 11000_ZOZOOFFICE " sync_point = 1 type = " IP " uniqueid = " 1000_ZOZOOFFICE " } 4. コード化 Terraformの管理下にネットワークリストが入ったものの、まだコード上には反映されていません。この状態でTerraform applyしてしまうと設定が空になってしまうため、インポートした内容をもとにコードを修正します。 terraform state show を用いて表示された設定情報をもとに空で作っていたリソースを更新します。 resource "akamai_networklist_network_list" "zozo_office_ip_list" { description = "Office IP List" list = [ "a.a.a.a" , "b.b.b.b" , ... ] mode = "REPLACE" name = "ZOZO_OFIICE" type = "IP" } terraform plan を打って無事にNo Changeとなっていればインポート完了です。 内容を更新する際には、コードを更新した後に terraform apply することで設定を更新できます。 コード化によって 弊社ではコードの管理にGitHubを利用しています。AkamaiのネットワークリストをIaC化したことで、一般的なIaC化のメリットとなりますが以下の効果が得られました。 更新履歴が手軽に追える GitHubのコミット履歴をみることで誰がいつどこで何のために更新したのか見れるようになった 設定情報を手軽に確認できる 設定内容を確認するために今まではログインして等の手順が必要だったが、設定内容の書かれたリポジトリをPullするだけで最新の内容を確認できるようになった コード化されたことでコンソールからはできなかった検索ができるようになった 更新する設定のレビューをメンバーに依頼できる 一人で作業することがなくなりチーム内での情報共有が手軽になっただけでなく、更新内容を事前にしっかりメンバーと共有できているという事実が心理的な安全性を向上させてくれた 無事に「設定の内容が可視化できていない」という課題点をTerraformを用いて解決できました。設定情報が手軽に確認できるようになったことで重複のあったリストの整理や不要なリストの削除が進みました。削除作業の際にも履歴をコミットとして残せるため作業の途中経過を残すことができ、管理がしやすかったです。 まとめ どうしても古くから利用しているSaaSやインフラは煩雑、複雑になってしまいがちです。部分的なところからでもIaCを導入することで開発効率の向上に少しづつ繋げていけます。 今回はAkamaiのネットワークリストをTerraformを用いてIaC化することで、いままで感じていた不便だった点を解消できました。今回IaC化のツールとしてTerraformを選択し、AkamaiのTerraformモジュールが整備されていることもありとても手軽にIaC化ができました。AkamaiのTerraformモジュールは頻繁にアップデートされており、IaC化の作業をしている間にも頻繁にアップデートがされていました。 すべてをIaC化することも良いと思いますが、まずはIaC化する意味があるところから少しずつ進めてみてはいかがでしょうか。 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 hrmos.co また、カジュアル面談も随時実施中です。「話を聞いてみたい」のような気軽な感じで大丈夫ですので、是非ご応募ください。 hrmos.co
アバター
はじめに こんにちは。カート決済部の林です。ZOZOTOWN内のカートや決済の機能開発、保守運用を担当しています。 過去に福袋販売イベントの負荷対策の記事を掲載しました。 techblog.zozo.com 上記の記事では、タイムアウトしたプロセスがロックを掴んだままになっていたことが原因で、大量のブロッキングが発生していました。詳細な負荷や対策の内容について知りたい方は、ぜひ上記の記事を読んでみてください。 こちらの原因を解決するために、 XACT_ABORT の設定を ON にすることが有効であると記載しています。 XACT_ABORT はトランザクション内でエラーが発生すると即座にロールバック+ロックの解放を指示できるオプションです。このオプションを ON にすることで、タイムアウトした時点でロックが解放され、ブロッキングが発生しなくなりました。 ただし、設定を ON に変えると一部動作が変わり、既存の処理が正常に動かなくなることがあります。弊社で ON にした際も一部のストアドプロシージャ(以下ストアド)が正常に動かなくなりました。その時に XACT_ABORT の動作について確認したので、その確認内容について本記事で紹介したいと思います。 XACT_ABORT の動作について知りたい方や、これから ON にしようとしている方などの参考になれば幸いです。 目次 はじめに 目次 XACT_ABORTの概要 動作確認の準備 トランザクション内での動作 XACT_ABORTがOFFの場合 XACT_ABORTがONの場合 ストアド内での動作 XACT_ABORTがOFFの場合 XACT_ABORTがONの場合 注意点 まとめ 最後に XACT_ABORTの概要 XACT_ABORT はSQL Serverのオプションの1つで、デフォルトでは OFF になっています。 ON/OFF それぞれの動作を以下の表に記載します。 設定 エラー時の動作 後続の処理 OFF 発生した処理のみがロールバック 実行される場合がある ON トランザクション全体が終了しロールバック 実行されない 実際に上記の動作についてテスト用のテーブルを作成し動作確認を行います。 動作確認の準備 実際に動作を確認するために、テスト用のテーブルと初期レコードを準備します。テーブル作成は以下のDDLを実行し、 table1 と table2 を作成します。このとき、動作確認時にエラーを起こしやすくするために、 table1 の col1 と table2 の col1 に外部キー制約を設定します。 CREATE TABLE table1 (col1 INT NOT NULL PRIMARY KEY); CREATE TABLE table2 (col1 INT NOT NULL REFERENCES table1(col1)); 初期のレコードとして以下のクエリを実行し table1 に4レコード INSERT します。 INSERT INTO table1 VALUES ( 1 ); INSERT INTO table1 VALUES ( 3 ); INSERT INTO table1 VALUES ( 4 ); INSERT INTO table1 VALUES ( 5 ); ここまでで準備完了になります。 トランザクション内での動作 XACT_ABORT がOFFの場合 動作確認のために以下のクエリを実行します。 SET XACT_ABORT OFF; GO BEGIN TRANSACTION; INSERT INTO table2 VALUES ( 1 ); INSERT INTO table2 VALUES ( 2 ); -- 外部キー制約によりエラーになる INSERT INTO table2 VALUES ( 3 ); COMMIT TRANSACTION; GO 1行目で XACT_ABORT を OFF に設定します。その後トランザクションを開始します。トランザクション内で table2 に3回の INSERT を行います。1回目と3回目の INSERT は正常に完了します。しかし、2回目の INSERT では table1 の col1 に2が入っているレコードが存在しないため、外部キー制約により失敗します。3回の INSERT が終わった後に COMMIT TRANSACTION を行います。 では、この時に table2 のレコードはどうなっているでしょうか。以下のクエリで見てみます。 SELECT * FROM table2 クエリの結果は以下になります。 2回目の INSERT でエラーになっているのですが、1,3回目の INSERT は反映されています。つまりは、 XACT_ABORT が OFF の場合には一部のクエリがエラーになってもそのまま処理が続けられます。なので、エラーハンドリングを自前で行う必要があります。 例えば今回の場合に、2回目の INSERT が失敗したら全てロールバックするには以下のように TRY-CATCH で囲います。 SET XACT_ABORT OFF; GO BEGIN TRY BEGIN TRANSACTION; INSERT INTO table2 VALUES ( 1 ); INSERT INTO table2 VALUES ( 2 ); -- 外部キー制約によりエラーになる INSERT INTO table2 VALUES ( 3 ); COMMIT TRANSACTION; END TRY BEGIN CATCH ROLLBACK TRANSACTION; END CATCH GO XACT_ABORT がONの場合 では次に ON の場合の動作を見ていきます。 OFF の動作確認時の table2 のレコードを以下のクエリで削除します。 DELETE table2 以下のクエリで動作確認をします。 SET XACT_ABORT ON ; GO BEGIN TRANSACTION; INSERT INTO table2 VALUES ( 1 ); INSERT INTO table2 VALUES ( 2 ); -- 外部キー制約によりエラーになる INSERT INTO table2 VALUES ( 3 ); COMMIT TRANSACTION; GO クエリの内容としては1行目で XACT_ABORT を ON にした以外は OFF の動作確認と同じです。このクエリを実行後に以下のクエリで table2 のレコードを見ます。 SELECT * FROM table2 取得できたレコードの件数は0件になります。つまりは、 XACT_ABORT が ON の場合にエラーが発生すると、その時点で処理が終わりロールバックされます。なので、 ON の場合に自前でロールバックを行う必要はありません。 ストアド内での動作 XACT_ABORT がOFFの場合 XACT_ABORT が OFF で動作するストアドを作成します。 CREATE PROCEDURE TEST_PROC AS SET XACT_ABORT OFF; BEGIN TRANSACTION; INSERT INTO table2 VALUES ( 2 ); -- 外部キー制約によりエラーになる IF @@ERROR = 0 BEGIN COMMIT TRANSACTION; RETURN 0 END ELSE BEGIN ROLLBACK TRANSACTION; RETURN 1 END GO table2 への INSERT の結果により返り値を変えています。本記事の環境では table1 の col1 に 2 が入っているレコードが存在しないため、外部キー制約によるエラーになります。そのため、返り値は1となりロールバックされます。 作成したストアドを以下のクエリで実行します。 DECLARE @ Return INT EXEC @ Return = TEST_PROC SELECT ' Return Value ' = @ Return GO すると以下の結果になります。 XACT_ABORT がONの場合 では次に XACT_ABORT の設定を ON に変えます。 ALTER PROCEDURE TEST_PROC AS SET XACT_ABORT ON ; BEGIN TRANSACTION; INSERT INTO table2 VALUES ( 2 ); -- 外部キー制約によりエラーになる IF @@ERROR = 0 BEGIN COMMIT TRANSACTION; RETURN 0 END ELSE BEGIN ROLLBACK TRANSACTION; RETURN 1 END GO 変更点は3行目の XACT_ABORT の設定を ON に切り替えたところのみです。変更後のストアドを以下のクエリで実行します。 DECLARE @ Return INT EXEC @ Return = TEST_PROC SELECT ' Return Value ' = @ Return GO Return Value が返ってこなくなります。これは XACT_ABORT の設定により処理が中断されたためです。返り値で1を返せるようにするには、 @@ERROR でハンドリングしていたところを TRY-CATCH にする必要があります。 TRY-CATCH に修正したストアドが以下になります。 ALTER PROCEDURE TEST_PROC AS SET XACT_ABORT ON ; BEGIN TRANSACTION; BEGIN TRY INSERT INTO table2 VALUES ( 2 ); -- 外部キー制約によりエラーになる END TRY BEGIN CATCH ROLLBACK TRANSACTION; RETURN 1 END CATCH COMMIT TRANSACTION; RETURN 0 GO 変更後のストアドを以下のクエリで実行します。 DECLARE @ Return INT EXEC @ Return = TEST_PROC SELECT ' Return Value ' = @ Return GO 結果は以下のようになり、 XACT_ABORT が OFF の時と同じように返り値が 1 になることが確認できます。 このように、 @@ERROR でハンドリングしている処理がある場合には修正が必要になります。 弊社では @@ERROR でハンドリングしているストアドがあり、 ON にするタイミングで @@ERROR のハンドリングから TRY-CATCH を使ったハンドリングへ修正しました。 注意点 本記事ではSQL Server Management Studioからクエリを実行して XACT_ABORT の動作を確認しています。そのため、アプリケーションでトランザクションを張った場合などは異なる動作をすることがあります。各自の実行環境で動作確認してから導入してください。 また、外部キー制約時のエラーの動作を中心に記載しました。この動作はエラーの重要度レベルによって変わることがあります。エラーレベルが異なる場合も各自で動作を確認してみてください。エラーレベルに関しては以下を参照してください。 docs.microsoft.com まとめ 本記事では XACT_ABORT の動作について紹介しました。紹介した動作について以下の表にまとめます。 設定 エラー時の動作 ロールバック処理の記述 ストアド内でのハンドリング方法 OFF 発生した処理のみがロールバック 全ての処理をロールバックしたい場合は処理を書く必要がある @@ERROR でのエラーハンドリングが可能 ON トランザクション全体が終了しロールバック 自動でロールバックされるので書く必要がない TRY-CATCH でのエラーハンドリングが可能 最後に カート決済部では負荷軽減の対策から、新機能開発、カート決済リプレイスなどを行っておりタスクが山積みの状態です。このような課題を一緒に進めていただける仲間を募集しています。ご興味のある方は以下のリンクから是非ご応募ください。 hrmos.co hrmos.co また、カジュアル面談も随時実施中です。是非ご応募ください。 hrmos.co
アバター
はじめに ZOZOMO部プロダクト開発ブロックの木目沢です。 ZOZOMO で提供しているZOZOTOWN上での「ブランド実店舗の在庫確認・在庫取り置き」APIの開発に携わっています。 今回は、開発当初から現在に至るまでのユニットテスト戦略についてお話しします。 意識してテストを書いていたのにカバレッジが低い問題 2021年11月にリリースされたブランド実店舗の在庫確認・在庫取り置きの機能ですが、開発当初のユニットテスト方針は以下のようなものでした。 モデルのユニットテストは必ず書く モデル以外の箇所は可能な範囲でユニットテストを書く 当時は実装のコードよりテストコードを先に書くといった文化はなく、レビューでテストの有無や内容を指摘する程度のものでした。 カバレッジも取っており、GitHub上では見える化していたものの、いつの間にか確認する機会も失われていきました。 もちろん、リリース前にはQAチームによるUIのテストも通り、十分なテストを経てリリースされています。しかし、当時のカバレッジは60%程度。カバレッジの数値というのは結果であってカバレッジの数値を上げることが目的ではないものの、今後安全に保守していくには心もとないものでした。 カバレッジは何%あるのが妥当か? 60%で心もとないと思ったのは、以前マーチン・ファウラー氏の テストカバレッジに関するブログ を読んだことがあったためです。ポイントを引用します。 思慮深くテストを実施すれば、テストカバレッジはおそらく80%台後半か90%台になるだろう。 カバレッジの数値が低い場合、たとえば50%以下の場合は、おそらく問題があるだろう。高いカバレッジの数値にはあまり意味はない。ダッシュボードの数字に意味がなくなる助けをするだけだ。 以下の質問に「はい」と答えられるならば、おそらくテストは十分だろう: 本番環境で発見されるバグはほとんどない。そして、 本番環境でバグを出すことを恐れてコードの変更をためらうことがない。 50%以下ということはありませんでしたが、それでも「本番環境でバグを出すことを恐れてコードの変更をためらうことがない」とは言えない状況でした。 TDDとTDD is deadへの誤解 カバレッジを上げる必要があると考え、まず思いついたのは「テスト駆動開発(以下TDD)」でした。テストを先に書けば自ずとカバレッジが上がると考えました(後述しますが、この考えは間違っています)。 一方で、同時に「TDD」に関して思い出したのは、 「TDD is dead. Long live testing.」 という言葉でした。2014年に発表されたRuby on Railsの作者としても有名なDavid Heinemeier Hansson氏のブログです。 インパクトのあるタイトルが界隈を賑わせましたが、タイトルだけで判断すると誤ります。そして実際誤っていました。TDDは意味がない。最終的にテストがあればよいのだと。 ブログの記事をよく読むと、以下のことが述べられています。 伝統的な意味でのユニットテストはほとんどしない。 テストファースト原理主義 ユニットテストやテストファーストという表現に「伝統的な意味での」という接頭辞や「原理主義」という接尾辞がありました。伝統的とか原理主義というのは、どのような意味なのか、以下抜粋します。 私は伝統的な意味でのユニットテストはほとんどしない。すべての依存関係をモックにし、何千というテストが数秒で終わるようなユニットテストのことだが。 テストファーストのユニットテストは、中間的オブジェクトや間接的で過剰に複雑な構造を生みがちだ。「遅い」ものをすべて避けようとするのがその理由で、データベースやファイルIOなどを避ける。ブラウザを使ってシステム全体をテストするのも避けようとする。 批判しているのは、モックを大量に使ってすべてをテストファーストで設計する手法であったり、データベースやE2Eテストも避けようとするやり方であったりします。 書籍「テスト駆動開発」 の付録では、TDDの歴史の流れから説明されていて、David氏がどのような経緯でこの考えにたどり着いたかが詳しく説明されていますのでご一読されることをおすすめします。 もう一度TDD。そしてTDDとは何か? 書籍「テスト駆動開発」 にきちんとTDDとはなにかということが記載されています。 開発者が設計の治具としてテストコードを同時に書きながら開発と改善を回していくというTDDの姿(KentBeck/テスト駆動開発より) これをもう一度見直そうと書籍を読み直しました。 そこで、気がついたのは以下のようなTDDにおけるテストと実装の流れでした。 テストを書く。実行すると実装前なのでエラーになる(RED) グリーンとなる実装を書く(GREEN) リファクタリングし、詳細に実装していったり、整えたりする(REFACTORING) 以下繰り返し つまりテストを最初に書くテストファーストだけがTDDのすべてではなく、実装し、テストを頼りにリファクタリングとしていく全体の流れこそがTDDです。 プロジェクトへのTDD導入 ここまで理解したところで、TDDをプロジェクトに導入しました。徐々にカバレッジも上がりましたが、あくまでTDDはタスクを実現するための設計手法であって、品質を保証するためのものではありません。テストを先に書けば自ずとカバレッジが上がると当初は考えましたが、TDDとカバレッジの関連は本来ありません。もちろん、TDDによって他のテストも書きやすくなり、結果的にカバレッジが上がっていくことはあると思います。 TDDを主にモデル層に導入したところ気がついたのはビジネスロジックが「モデルだけ」に集まることです。タスクを実現する実装コードをモデルだけで実現するように書いていくことで、他の層やSQLに書かれることがなくなりました。 この点がTDDにおいて重要なところになります。テストによって設計を駆動していく手法、まさに、Test Driven Developmentということです。 データベースのテストもAPIのテストもやる TDDを進めていくにつれて、データベース接続のテストや、APIのコントローラーのテストなどモデル以外の層のテストも書くようになっていきました。この辺りをモックにしてテストの実行時間を短くするという考えもあるかと思いますが、それこそまさにDavid氏が批判してきたところです。 現在では、データベースのテストもJavaであればDBUnitを使うことで、Spring BootやMyBatisと連携したテストが書けます。コントローラもSpring BootではWebMvcTestが容易に使えるなど、テストツールが充実しています。また、時間がかかるテストもCIで回せばそこまで大きな負担とはなりません。 ユニットテストに加えて、E2Eのテストも Karate などで容易に導入できます。弊チームでも導入し始めており、仕様の確認から実装後の確認までE2Eテストを活用しています。 こうして、プロジェクトではTDDやその他ツールを活用し、充分に成果があがるようになってきました。ここでさらに社内にもTDDを広めようと2つの活動を開始しました。 TDDを活用したライブコーディング会 「ブランド実店舗の在庫確認・在庫取り置き」APIの開発にあたり、開発当初から相談に乗っていただいた技術顧問の かとじゅんさん を交え、TDDを活用したライブコーディング会を定期的に開催しています。 もともとはドメイン駆動設計を実際のソースを書きながら学習していくことが主目的でした。それをTDDで実践したほうが理解しやすいと、かとじゅんさんからのご提案でこの形になりました。 社内においてもTDDをすぐに実践できるぐらいに広められる良い機会となりました。 TDD写経会 書籍「テスト駆動開発」 の付録にある通り、写経も試してみた結果、理解が深まったため、社内で写経会もはじめました。 当ブログ執筆時点(2022年08月16日)で20回開催され、都度みんなで理解を深めています。 テスト駆動開発は、実際に手を動かしてみないと理解が難しい技法です。本書も、読んだだけでは深い得心には至らないでしょう。しかし、テスト駆動開発の良さ、強みは手を動かせばわかります。なぜなら、TDDの本質は精神状態のコントロール、不安と自信の制御にあるからです。結果(書かれたコードとテストコード)ではなく、過程(思考プロセスとリファクタリング)に本質があります。(KentBeck/テスト駆動開発より) 書籍に書いてあることを理解するには、実際に手を動かして試してみることがTDDにおいては理解を深める近道で、書籍のサンプルコードは本当にゆっくりしたペースと手順で解説されています。 1行1行そのままサンプルコードを写して、テストを実行することで初めて気づくことが多いです。 以下の画像は、写経会で私が共有した感想になります。 TDDのやり方とコツ TDDのやり方は先程紹介したとおり、テストを書く。グリーンになるように実装を書く。リファクタリングをする、という流れを繰り返す単純なものです。しかし、単純だからこそ、うまく実行するコツが必要です。ここで書籍や、ライブコーディング会、写経会を経て掴んだコツを紹介したいと思います。 (1)テストを書く。実行すると実装前なのでエラーになる(RED) TDDにおけるテストとはタスクのことです。タスクを実現するようなテストをまず書くことでそれが設計となります。そのため、まず最初にすることはタスクの洗いだしです。タスクひとつひとつがテストとなり、テストの内容が設計となり、実装されていくイメージです。 テストメソッド名、つまりタスクは「日本語」で書いています。タスクをそのままテスト名にすることで、実装のイメージがつきやすくなりました。日本語なんてと思われる方も多いかと思いますが、文字コードの問題も起こらず使用できています。 例えば以下のようなイメージです。 public class StringObjectTest { @Test public void 2つの文字列を接続し結合した文字列を返す(){ StringObject str = new ReturnString( "str" ); str.concat( "concatString" ) Assertions.assertEquals( "strconcatString" , str.value); } } (2)グリーンとなる実装を書く(GREEN) まずは、最短でグリーンになるように書くことが大事です。データベースに接続するようなタスクであれば、とりあえずMapで代用しても問題ありません。文字列を返すようなメソッドであれば何もせず適当な文字列を返しても大丈夫です。このような実装を仮実装と言います。その後はGREENを維持しながら実装とリファクタリングを繰り返していくことで安心して本実装ができます。 先の例を実装すると以下のとおりです。本番用のコードとしてはありえないと思いますが、まずは最短でグリーンになる実装を書くのがコツです。 public class StringObject { String value = "str" + "concatString" ; public void concat(String str){ } } (3)リファクタリングし、詳細に実装していったり、整えたりする(REFACTORING) TDDで一番使われるリファクタリングのテクニックは「重複の除去」になります。そして、TDDでいう「重複」とは「テストコード」と「実装コード」間の重複や、「文字列や数値」の重複を含みます。例を挙げます。 public class StringObjectTest { @Test public void 2つの文字列を接続し結合した文字列を返す(){ StringObject str = new ReturnString( "str" ); str.concat( "concatString" ) Assertions.assertEquals( "strconcatString" , str.value); } } テストは上記のようなコードでした。それに対しての仮実装は以下のようなものでした。 public class StringObject { String value = "str" + "concatString" ; public void concat(String str){ } } この仮実装とテストを比較すると、重複しているのは「str」や「concatString」の文字列になるので、それを除去していきます。 以下はリファクタリングした例になります。 public class StringObject { String value = "str" ; public void concat(String str){ value = value + str } } これで、「concatString」が除去され、少しまともな実装コードになりました。この繰り返しできちんとした実装コードにしていくことができます。しかもテストが既にあるので、安心してリファクタリングが可能です。 まとめ〜いまさら? いまこそTDD KentBeck氏によって 書籍「テスト駆動開発」 が最初に出版されたのは2002年。それからちょうど20年が経ちました。今でもTDDのやり方に慣れない、導入できない、内容を誤解している方も多いのではないでしょうか。 いまこそTDDを見直してみてプロジェクトに導入してみてください。 今回は、弊チームでのユニットテスト戦略のテーマでお伝えしました。開発当初はモデルへのユニットテストのみを重視していました。しかし、TDDを見直すことで、仕様の策定からタスク化・設計・各層における実装のテストまでプロジェクトのサイクル全般をテストでカバーする開発スタイルへと変化しています。 ZOZOMO部では、TDDはもちろん、サーバーレスアーキテクチャやイベントソーシング、ドメイン駆動設計などを活用しサービスを成長させたい仲間を募集中です。ご興味ある方は こちら からぜひご応募ください! corp.zozo.com
アバター
こんにちは、MA基盤チームの田島です。私達のチームではMAIL、LINE、PUSH通知といったユーザへの配信をしています。その中でもマス・セグメント配信という一斉に行う配信では、配信対象者のセグメント抽出にBigQueryを利用しています。また、配信前に必要なデータをBigQueryに連携しデータマートの集計をしたり、配信後には配信実績の登録などの更新処理をしています。 そのような処理を定期的に行っているため、ネットワークの問題やサーバーの不調などにより処理が途中で失敗することがあります。そこで、リトライを容易にするため、すべての処理を冪等にしました。今回その中でも、BigQueryの追記処理に絞ってどのように冪等化したのかについて紹介します。 目次 目次 マス・セグメント配信基盤の紹介 課題 冪等化 BigQuery追記処理に関する冪等化の取り組み 冪等にならないケース INSERT 初期データ 最初のINSERT処理 INSERT後のデータ INSERT処理をリトライ リトライ後のデータ DestinationTableのWRITE_APPEND 初期データ 参照元のデータ 最初のWRITE_APPEND処理 WRITE_APPEND後のデータ WRITE_APPEND処理をリトライ リトライ後のデータ 3種類の冪等化 Overwrite 例)データ連携 例)データマート更新 使い所 Copy Table & Append 例)データ連携 使い所 Create Table & Merge 例)SELECT結果をAPPEND ひと工夫 もうひと工夫 使い所 比較 冪等化の結果 まとめ マス・セグメント配信基盤の紹介 まず、各配信の流れを簡易化したものが以下になります。 上記の流れはDigdagというワークフローエンジンを利用することで実現しています。 Digdagの基盤については、以下のテックブログをご参照ください。 techblog.zozo.com 課題 バッチ処理においては、ネットワークの問題やサーバーの不調により予期せぬタイミングで処理が異常終了することもあります。発生時にはDigdagのリトライ機能を利用し、処理単位ごとに自動リトライしています。しかし、なにも考えずにリトライするとデータが重複して登録されたり、同じ配信を複数回行ってしまうといった問題が起こり得ます。 BigQueryの更新処理については、最近導入された Transactionの仕組み があります。この機能を使うことで一連のクエリをTransactionの中で完結できます。しかし、クエリの更新処理がすべて完了しCommitの完了後、アプリケーションやDigdagで異常が発生することもあります。そのときリトライすると同じトランザクション処理を再度実行してしまい、同じ処理が重複して実行されてしまいます。 このような事象はかなりレアケースではありますが、安定したバッチ処理をするにはこの問題に関しても対応する必要があります。 冪等化 上記の課題を解決するためにデータの更新処理を含め、すべての処理を冪等化しました。それにより、処理が失敗した場合はただリトライをするだけで整合性が担保されるようにしました。そして、リトライは自動的に行われているので、手動でのオペレーションも不要です。 BigQuery追記処理に関する冪等化の取り組み ここからはBigQueryのデータ処理に限定して、冪等化の取り組みについて紹介していきます。今回扱う更新処理は追記処理に限定しています。 冪等にならないケース BigQueryの追記処理において冪等にならないようなケースについてのパターンを紹介します。今回紹介するケースの他に、自己参照したデータを利用するUPDATE文も冪等にならないことがあります。しかし、私達のシステムではBigQueryにおいてそのような処理を行っていないため、今回は省略し追記処理に限定しました。 INSERT 最初はINSERT文です。BigQueryはユニークキー制約が無いため、INSERT処理は常に追記されることになります。よって、INSERT成功後に処理が失敗しリトライされてしまうとデータの重複が発生します。 PUSH通知の実績テーブルである push_delivered テーブルへのINSERT処理を例に、リトライによりデータが重複するケースを紹介します。 初期データ まず初期データとして以下の2レコードが存在するとします。 user_id message delivered_at 1 message1 2022-07-26 12:00:00 2 message1 2022-07-26 12:00:00 最初のINSERT処理 push_delivered テーブルに対し以下のINSERT処理を行います。 INSERT INTO `project.dataset.push_delivered` (user_id, message, deliverd_at) VALUES ( 3 , " message2 " , " 2022-07-26 12:00:00 " ); INSERT後のデータ INSERT処理が成功すると以下のようにレコードが1行追加されます。これが正しいデータになります。 user_id message delivered_at 1 message1 2022-07-26 12:00:00 2 message1 2022-07-26 12:00:00 3 message2 2022-07-26 13:00:00 INSERT処理をリトライ リトライされた場合、もう一度以下のINSERT処理が行われます。 INSERT INTO `project.dataset.push_delivered` (user_id, message, deliverd_at) VALUES ( 3 , " message2 " , " 2022-07-26 12:00:00 " ); リトライ後のデータ リトライされると、以下のように user_id=3 のデータが重複してしまいます。 user_id message delivered_at 1 message1 2022-07-26 12:00:00 2 message1 2022-07-26 12:00:00 3 message2 2022-07-26 13:00:00 3 message2 2022-07-26 13:00:00 DestinationTableのWRITE_APPEND 続いて紹介するのが、BigQueryのDestinationTableという機能です。 cloud.google.com これは、SELECTした結果をテーブルに書き込むことができる機能で、書き込み方法としては以下の3種類が存在します。 WRITE_EMPTY WRITE_TRUNCATE WRITE_APPEND WRITE_EMPTY と WRITE_TRUNCATE はSELECT文の結果をそのまま指定したテーブルに書き込みます。 WRITE_EMPTY の場合はテーブルのデータが存在しない場合のみ書き込みを行います。そのため、これらの処理は何度実行しても最終的なデータは同じになります。 WRITE_APPEND の場合はSELECT文の結果を指定したテーブルに追記します。そのため、同じ処理を何度も行うとそのたびに同じデータが追記され、重複データが生じてしまいます。以下にデータが重複するケースを紹介します。 初期データ まず先程の例と同じように、初期データとして以下の2レコードが存在するとします。 user_id message delivered_at 1 message1 2022-07-26 12:00:00 2 message1 2022-07-26 12:00:00 参照元のデータ 今回、WRITE_APPENDするデータとして push_delivered_20220727 というテーブルを用意します。 user_id message delivered_at 3 message2 2022-07-27 12:00:00 最初のWRITE_APPEND処理 push_delivered テーブルに対し以下のSELECT処理の結果を追記します。 SELECT * FROM `project.dataset.push_delivered20220727` WRITE_APPEND後のデータ SELECT APPEND処理が成功すると以下のようにレコードが1行追加されます。これが正しいデータになります。 user_id message delivered_at 1 message1 2022-07-26 12:00:00 2 message1 2022-07-26 12:00:00 3 message2 2022-07-26 13:00:00 WRITE_APPEND処理をリトライ リトライされた場合、もう一度以下のSELECT APPEND処理が行われます。 SELECT * FROM `project.dataset.push_delivered20220727` リトライ後のデータ リトライされると、以下のように user_id=3 のデータが重複してしまいます。 user_id message delivered_at 1 message1 2022-07-26 12:00:00 2 message1 2022-07-26 12:00:00 3 message2 2022-07-26 13:00:00 3 message2 2022-07-26 13:00:00 3種類の冪等化 上記で紹介したような処理を冪等化するために、3種類のパターンを利用しています。 Overwrite Copy Table & Append Create Temp Table & Merge それぞれ、どのようなときにどのパターンを利用しているのかについて紹介します。 Overwrite 一番簡単なやり方は、全量のデータをまるごと洗い替えする方法です。以下がデータ更新の流れです。 2つの例を元に、具体的な処理を紹介します。 例)データ連携 最初の例として、PostgreSQLに存在するテーブル push_delivered をBigQueryのテーブルに連携する場合を考えます。ここでは、データ連携にEmbulkというツールを利用したケースを紹介します。 以下がデータ連携を実現するためのEmbulkの設定ファイルです。 in : type : postgresql host : {{ env.postgres_host }} user : {{ env.postgres_user }} password : {{ env.postgres_password }} database : db query : SELECT * FROM push_delivered out : type : bigquery project : {{ env.bq_project }} dataset : {{ env.bq_dataset }} table : push_delivered mode : replace with_rehearsal : false source_format : NEWLINE_DELIMITED_JSON 上記の設定でEmbulkを実行することで、PostgreSQLに存在するテーブル push_delivered の全量データをBigQueryのテーブルに連携します。このとき mode: replace を利用しているため、BigQueryのデータは毎回PostgreSQLのテーブルにあるデータで全量上書きされます。よって、この処理を何回繰り返したとしても、結果はPostgreSQLのテーブルのデータと同じになります。 例)データマート更新 続いてデータマートの更新について考えます。データマートの更新には、DestinationTable機能を利用します。ここではPythonクライアントを使った方法を紹介します。 以下は offer_delivered テーブルにある channel = 'PUSH' のデータを push_delivered マートとしてテーブルを作成している例です。 query=`SELECT offer_delivered WHERE channel = 'PUSH' ` client = bigquery.Client() destination_table = 'project.dataset.push_delivered' job_config = bigquery.QueryJobConfig( destination=destination_table, write_disposition= 'WRITE_TRUNCATE' ) query_job = client.query(query, job_config=job_config) DestinationTable機能のうち WRITE_TRUNCATE を利用しています。 query に格納したSELECT文を実行し、その結果をそのまま push_delivered というテーブルに上書きします。この処理に関しても、参照元のデータが更新されない限り何度実行しても更新対象テーブルである push_delivered のデータは最終的に同じになります。 使い所 この方法はただ単純にデータを全量上書きすれば良いので非常に実装がシンプルになります。しかし、全量のデータを扱うため、データ量が大きいものには向いていません。データ量が大きい場合、データの処理時間や費用増加といったデメリットがあります。 よって、使い所としてはそれほどデータ量が多くないテーブルの連携やデータマートの更新処理に向いています。これから紹介する手法の中でも一番シンプルかつ安定した方法になるため、データ量が許容できるのであればこの方法を利用することをおすすめします。 Copy Table & Append 次は既存のテーブルをコピーしてから INSERT や WRITE_APPEND する方法です。これは、データを差分更新する方法で、洗い替えをするにはデータ量が多すぎる場合に利用できます。以下がデータ更新の流れです。 更新対象のテーブルをバックアップする バックアップしておいたテーブルをtempテーブルにコピーする 追記したいデータをコピーしたテーブルに追記する 最後にtempテーブルを、実際に更新したいテーブルへコピーし、tempテーブルを削除する こうすることで、何度実行しても最終的にはバックアップテーブルに対して更新データ追記したものが更新対象テーブルのデータになります。ここでもデータ連携を具体例として紹介します。 例)データ連携 今回の例は1日1回データ連携することを想定します。以下の例では追記処理としてDestinationTableのWRITE_APPEND機能を利用します。また、ここではテーブルをバックアップしておく代わりに、BigQueryのタイムトラベル機能を利用します。タイムトラベル機能は過去の日時を指定することで、そのタイミングでのデータを参照できる機能です。詳しくは以下のドキュメントを参照してください。 cloud.google.com これを利用することで、テーブルのバックアップを管理する必要がなくなります。ただし、7日間までという成約があるため、1か月ごとのデータ更新等には利用できません。 まずはじめに、差分更新したいデータをPostgreSQLから連携します。これには、Embulkを利用します。以下では、PostgreSQLの push_delivered のうち当日登録されたデータをBigQueryの push_delivered_20220727 というテーブルに連携します。 in : type : postgresql host : {{ env.postgres_host }} user : {{ env.postgres_user }} password : {{ env.postgres_password }} database : db query : SELECT * FROM push_delivered WHERE CAST(delivered_at AS DATE) = current_date out : type : bigquery project : {{ env.bq_project }} dataset : {{ env.bq_dataset }} table : push_delivered_20220727 mode : replace with_rehearsal : false source_format : NEWLINE_DELIMITED_JSON データ連携後、以下のようなスクリプトで差分更新をします。 def copy_table (self, source_table_id, destination_table_id, write_disposition): copy_config = bigquery.CopyJobConfig(write_disposition=write_disposition) job = self.client.copy_table(source_table_id, destination_table_id, job_config=copy_config) job.result() def append (self, source_table_id, destination_table_id, base_time): base_datetime = dt.strptime(base_time, '%Y-%m-%d %H:%M:%S%z' ) snapshot_epoch = int (base_datetime.timestamp()) * 1000 snapshot_table_id = "{}@{}" .format(destination_table_id, snapshot_epoch) temp_table_id = "{}_temp" .format(destination_table_id) self.copy_table(snapshot_table_id, temp_table_id, 'WRITE_TRUNCATE' ) self.copy_table(source_table_id, temp_table_id, 'WRITE_APPEND' ) self.copy_table(temp_table_id, destination_table_id, 'WRITE_TRUNCATE' ) self.client.delete_table(temp_table_id) if __name__ == '__main__' : append(push_delivered_20220727, push_delivered, '2022-07-26 12:00:00+09:00' ) スクリプトの流れは以下のようになっています。 base_time='2022-07-26 12:00:00+09:00' を指定し、更新対象テーブルである push_delivered のいつ時点をバックアップとして扱うのかを設定 バックアップテーブルを 更新対象テーブル_temp という名前でコピー tempテーブルに対して差分データが格納された push_delivered_20220727 のデータを追記 tempテーブルを更新対象テーブルに上書き こうすることで、この処理を何回実行しても push_delivered は半日前のデータに push_delivered_20220727 を追記したデータとなります。任意のタイミングのデータを参照するには テーブル名@UNIXエポック時刻 という名前でテーブルを参照することで取得できます。 また、このとき BigQueryのCopyJob機能 を使っています。DestinationTableのときと同じように WRITE_TRUNCATE WRITE_APPEND をオプションとして指定できます。そのためテーブルのコピー時に、コピー先のテーブルにデータを上書きするか追記するかが選択可能です。CopyJobの利用には料金がかからないため、コスト削減にも繋がります。 使い所 この方法を利用することでデータの差分更新が可能となり、データ量が大きいテーブルに対する処理の更新量を減らすことができます。そのため、処理時間や費用を抑えることができます。ただし、一度データをバックアップからコピーし更新をするため、同じテーブルの更新を同時にできません。また、タイムトラベル機能を利用できない場合はバックアップデータを保持しておく必要があります。 Create Table & Merge 続いての手法は、Copy Table & Appendで扱えなかった、同じテーブルへの並列更新をしたい場合に利用できます。イメージとしてはUPSERT処理に近いです。以下がその流れです。 更新対象のテーブルのスキーマ情報のみをコピー コピーしたテーブルに対してデータを追記 ユニークキーを利用し、テーブル同士をマージ このようにすると、最初の実行ではデータが追記されます。その後リトライされた場合は、MERGE処理のタイミングですでにデータが書き込まれていないデータのみ追記します。よって、この処理を何回実行しても更新対象テーブルは最終的には同じデータになります。 具体例として、SELECT結果を更新対象テーブルに追記したいケースを紹介します。 例)SELECT結果をAPPEND 以下がSELECT結果を更新対象テーブルに追記するためのSQLになります。 CREATE TEMP TABLE insert_data AS SELECT * FROM `project.dataset.offer_delivered` LIMIT 0 ; INSERT INTO insert_data (user_id, message, delivered_at, channel) SELECT user_id, message, delivered_at, ' PUSH ' FROM `project.dataset.push_delivery` MERGE `project.dataset.offer_delivered` target USING insert_data ON (target.user_id = target.user_id and target.message = insert_data.message and target.delivered_at = insert_data.delivered_at) WHEN NOT MATCHED THEN INSERT ROW ; SELECT * FROM APPEND先のテーブル名 LIMIT 0 としてテーブルをコピーし insert_data という一時テーブルを作成 作成したTEMPテーブルに格納したいデータを INSERT MERGE文を使い、更新対象テーブルと一時テーブルをマージ 1の処理にはCopyTable機能も利用できますが、その場合最後に自分でテーブルを削除する必要があります。 LIMIT 0 でのSELECTはスキャンが走らないため料金はかかりません。また、データのマージには条件としてユニークキーを渡す必要があります。 ひと工夫 このとき、テーブルサイズが大きいとMERGE処理時にフルスキャンが走るため、処理時間および料金の増加が発生します。そこで更新対象テーブルをパーティション分割しておき、MERGE文の条件にパーティションの条件を入れることでその問題を解決できます。 CREATE TEMP TABLE insert_data AS SELECT * FROM project.dataset.offer_delivered LIMIT 0 ; INSERT INTO insert_data (user_id, message, delivered_at, channel) SELECT user_id, message, delivered_at, ' PUSH ' FROM `project.dataset.push_delivery` MERGE `project.dataset.offer_delivered` target USING insert_data ON (target.user_id = target.user_id and target.message = insert_data.message and target.delivered_at = insert_data.delivered_at and DATE (target.delivered_at) >= CURRENT_DATE ( ' Asia/Tokyo ' )) WHEN NOT MATCHED THEN INSERT ROW ; もうひと工夫 上記で見ていただいたように、この手法だとテーブル毎に同じような複雑なクエリを書く必要があります。また、ユニークキーやパーティションキーに関しても別途管理が必要になります。そこで以下のようなスクリプトでクエリを生成できるようにしました。ここではテンプレートエンジンに Jinja を利用しています。これにより、必要なデータは更新したいテーブル名と、追記したいデータを抽出するSELECT文のみになります。 def select_and_append (select_sql, target_table_id, partition_base_time = date.today()): TEMPLATE_PATH = 'write_append.tmpl' client = bigquery.Client() target_table = client.get_table(target_table_id) target_table_column_names = [column.name for column in target_table.schema] time_partition_column = target_table.time_partitioning partition_column_name = None if time_partition_column is not None : partition_column_name = time_partition_column.field if time_partition_column.field is not None else '_PARTITIONTIME' params = { 'select_sql' : select_sql, 'target_table_id' : target_table_id, 'target_table_column_names' : target_table_column_names, 'partition_column_name' : partition_column_name, 'partition_base_time' : partition_base_time } env = Environment(loader=PackageLoader( 'bigquery' , 'templates' )) sql = env.get_template(TEMPLATE_PATH).render(params) query_job = client.query(sql) query_job.result() if __name__ == '__main__' : select_sql = 'SELECT user_id, message, delivered_at, ' PUSH ' FROM `project.dataset.push_delivery` select_and_append(select_sql, ' project.dataset.offer_delivery ') CREATE TEMP TABLE insert_data AS SELECT * FROM `{{ target_table_id }}` LIMIT 0 ; INSERT INTO insert_data SELECT {% for column_name in target_table_column_names %} {{ column_name }} {% if not loop . last %} , {% endif %} {% endfor %} FROM ( {{ select_sql }} ); MERGE `{{ target_table_id }}` target USING insert_data ON ( {% for column_name in target_table_column_names %} target.{{ column_name }} = insert_data.{{column_name}} {% if not loop . last %} and {% endif %} {% endfor %} {% if partition_column_name is not none %} and DATE (target.{{ partition_column_name }}) >= parse_date( ' %Y-%m-%d ' , ' {{ partition_base_time }} ' ) {% endif %} ) WHEN NOT MATCHED THEN INSERT ( {% for column_name in target_table_column_names %} {{ column_name }} {% if not loop . last %} , {% endif %} {% endfor %} ) ROW ; 使い所 この手法を利用することで並列に同じテーブルへの追記処理が実施されても問題なく更新できます。また、上記のように1つのクエリで完結します。ただし他の手法に比べてMERGE処理のタイミングでスキャンが行われるため、処理時間および料金の増加が生じます。追記するデータ量が大きい場合は、MERGE文の条件判定に時間もかかってしまいます。 よって、使い所としては間隔が一定でないような追記処理や、追記が並列で実施されるような処理に向いています。 比較 以上を踏まえ、各処理に関しての比較をまとめました。 手法 メリット デメリット 使い所 Overwirte ・シンプル ・扱うデータ量が大きいと使えない ・並列での更新が不可 データ量が多くない更新処理 Copy Table & Append ・差分更新ができる ・差分更新する方法の中ではシンプル ・一定期間毎の更新で無いと使えない ・並列での更新が不可 ・一定間隔ごとの更新処理 ・データ量が多い場合 Create Table & Merge ・並列での更新が可能 ・複雑 ・データ量が多い場合は考慮すべき点がある ・並列での更新処理を行いたい場合 冪等化の結果 紹介したような仕組みを利用し、バッチ処理すべてを冪等化しました。それにより、データ不整合の危険や重複配信などの潜在的な問題を事前回避できるようになりました。そして、すべての処理に対して自動リトライを入れることで、一時的な問題に対して手動でのオペレーションがなくなりました。以上のように、冪等化によって安定したバッチ処理を実現できました。 まとめ 今回BigQueryの追記処理に関する冪等化の取り組みについて紹介しました。私達のチームでは、安定した仕組み開発がしたい人を募集しています。興味がある方は以下のリンクからご応募ください。 hrmos.co
アバター
こんにちは。ZOZO ResearchのResearcherの平川と古澤です。2022年7月25日(月)から7月28日(木)にかけて画像の認識・理解シンポジウムMIRU2022に参加しました。この記事では、MIRU2022でのZOZO Researchのメンバーの取り組みやMIRU2022の様子について報告します。 目次 目次 MIRU2022 企業展示 インタラクティブセッション [OL3B-3]条件付き集合変換を用いたファッションコーディネートの補完 (ロングオーラル) [IS3-27]ファッション推薦問題に向けた階層的集合マッチングモデルの検討 [IS3-55]身体と衣服の採寸情報を考慮する仮想試着のためのレイアウト生成モデルの検討 気になった研究発表 [OL1B-4] Cross-Modal Recipe Embeddingを用いたマスクに基づく食事画像生成 [OL2A-2] 深層モデルの汎化性能改善を目的とした特徴抽出器の事後学習 [IS3-73] 布生地の風合いの画像認識 [OL3A-3] Database-adaptive transfer learning for question answering-based re-ranking in cross-modal retrieval オフライン参加によって得られた気付き 最後に おまけ MIRU2022 MIRUとは、Meeting on Image Recognition and Understandingという画像の認識・理解についてのシンポジウムです。2022年の今回はアクリエひめじ(姫路市文化コンベンションセンター)においてオフラインとオンラインのハイブリッド形式で開催されました。数年ぶりに現地参加も可能ということで1243名の方々が参加されたそうです。ZOZO NEXTは、このMIRU2022にゴールドスポンサーとして協賛させていただきました。 sites.google.com 企業展示 企業展示ブースでは、ZOZO Researchにおける取り組みについてポスターを用いて紹介しました。ZOZOの多角的なファッションサービスとそこから得られる情報資産を活用した研究事例について紹介させていただきました。大変うれしいことに多くの方々に興味を持っていただき、お話をさせていただくことができました。ブースまで足を運んでくださった皆さま、誠にありがとうございました。展示していたポスターはこちらです。 インタラクティブセッション ZOZO Researchからはロングオーラル1件とインタラクティブセッション2件の計3件を発表しました。以下に、各研究のサマリーを示します。 [OL3B-3]条件付き集合変換を用いたファッションコーディネートの補完 (ロングオーラル) 中村 拓磨、斎藤 侑輝 (ZOZO Research) ファッションコーディネート補完問題は、複数の衣服やアクセサリーの組み合わせからなるファッションコーディネートを推薦する技術を実現するための重要な課題として知られています。コーディネート補完問題は、完成したコーディネートに対する評価値計算を前提とする従来手法を用いる場合、補完候補アイテム集合から評価値が最大になるアイテムの組み合わせを探索する問題に帰着します[1][2]。しかしながら、補完候補アイテム集合の要素数が増大するにつれて、探索コストが増大するという課題があります。そこで、本研究ではコーディネート補完問題を指定の条件下における集合検索問題として定式化し、入力アイテム集合と補完候補アイテム集合の属性を反映した特徴量を生成可能なモデルとその学習手法を提案しました。提案手法は指定の条件下で入力アイテムの集合と相補的なアイテム集合を直接的に予測可能であるため、探索空間の増大に伴い推論時の計算量が増大する問題を原理的に解決するアプローチと言えます。実データを用いた性能比較実験では提案手法が入力アイテム集合と相補的なアイテム集合を予測できていること及び出力集合の要素の属性情報を制御可能であることを示しました。 [1] Cucurull, Guillem, Perouz Taslakian, and David Vazquez. "Context-aware visual compatibility prediction." Proceedings of the IEEE/CVF conference on computer vision and pattern recognition. 2019. [2] Saito, Y., Nakamura, T., Hachiya, H. and Fukumizu, K.: Exchangeable Deep Neural Networks for Set-to-Set Matching and Learning, ECCV2020: Proceedings, Part XVII, p. 626‒646. 2020. [IS3-27]ファッション推薦問題に向けた階層的集合マッチングモデルの検討 長瀬准平(ZOZO Research, 芝浦工大)、斎藤侑輝(ZOZO Research)、石渡哲哉(芝浦工大) ファッションコーディネート間のマッチング問題はファッションに関する様々な推薦タスクへの応用が期待される重要な問題です。コーディネートは複数のファッションアイテムからなる集合と見なせますが[2]、本研究ではコーディネートの集合間のマッチング問題を新たに提起し、階層的な相互作用を考慮した深層学習モデルを提案しました。提案手法は、置換不変な特徴量抽出器であるCrossSimilarity関数[2]を拡張した、コーディネートとアイテムという異なる階層の相互作用を考慮可能な階層的集合マッチングモデルから成ります。実データを用いた比較検証実験では従来手法と比較してマッチング精度が改善することを確認しました。 [2] Saito, Y., Nakamura, T., Hachiya, H. and Fukumizu, K.: Exchangeable Deep Neural Networks for Set-to-Set Matching and Learning, ECCV2020: Proceedings, Part XVII, p. 626‒646. 2020. [IS3-55]身体と衣服の採寸情報を考慮する仮想試着のためのレイアウト生成モデルの検討 後藤 亮介、中村 拓磨 (ZOZO Research) オンラインショッピングにおいては購入以前に衣服のサイズを正確に把握することが困難であるという課題があります。近年では画像ベース仮想試着技術の研究が盛んに行われていますが着用者の体型や衣服のサイズを明示的に考慮した研究がなされていないのが現状です[3][4][5]。本研究では、ZOZOTOWNのデータから着用者の身長と衣服の寸法を含むデータセットを構築し、衣服と身体の寸法を明示的に考慮したレイアウト生成モデルのベースラインモデルを学習しました。更に、身長や着丈の情報を反映できていることを確認するための定量評価指標を提案しました。 [3] Han, Xintong, et al. “Viton: An image-based virtual try-on network.” Proceedings of the IEEE conference on computer vision and pattern recognition. 2018 [4] Choi, Seunghwan, et al. “Viton-hd: High-resolution virtual try-on via misalignment-aware normalization.” Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition. 2021 [5] Neuberger, Assaf, et al. “Image based virtual try-on network from unpaired data.” Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition. 2020. 気になった研究発表 私たちが個人的に興味を持った研究について紹介します。 [OL1B-4] Cross-Modal Recipe Embeddingを用いたマスクに基づく食事画像生成 陳 仲涛、本部 勇真、楊 景、柳井 啓司 (電気通信大学) 敵対的生成ネットワークによる画像生成の技術の進展により、リアルな画像の生成が可能となってきています。一方で、料理画像を生成するタスクでは、料理は食材だけでなく作り方や盛り付けによっても見た目や形が大きく変わるという特性上、レシピや盛り付けを反映したリアルな料理画像の生成が難しかったそうです。この研究では、レシピテキストと料理領域から料理を料理領域に盛り付けた画像を生成するMask-based Recipe Embedding GAN (MRE-GAN)を提案されていました。レシピテキストから料理画像を生成するRDE-GANというモデルとセマンティック領域適応正規化を組みわせることで、より安定的に学習ができるようになったとのことです。実験結果として、スープのレシピから枝豆を抜くと生成された料理画像から枝豆が消えるという面白い事例を示されておりました。ファッションの分野でも仮想試着などの文脈で画像生成が注目を集めています。個人的には料理の調理方法というのは洋服の着こなしとも対応しているのかなと感じました。将来的にそういった研究もできると面白そうです。 [OL2A-2] 深層モデルの汎化性能改善を目的とした特徴抽出器の事後学習 山田 陵太、佐藤 育郎、田中 正行、井上 中順、川上 玲 (東京工業大学/デンソーITラボ) 深層モデルにおいて、局所解周りの平坦さがモデルの汎化性能と関係していることが示唆されています。例えばSharpness-Aware Minimizationと呼ばれる解の平坦性まで考慮した最適化手法では、より汎化性能の高い解を得やすいということが知られています。このため局所解近傍における損失形状の平坦化が重要です。この研究では、既に充分に学習された深層モデルを初期状態として、そこからさらに平坦な局所解を探索できる事後学習法を提案されていました。提案手法では、まず深層モデルの前半と後半を抽出器と識別器にわけ、識別器側にミニバッチ損失を最小化するような摂動を加えていました。その後、摂動された識別器の平均を最小化するように、さらに抽出器のパラメータを最適化されていました。このように構築されたモデルを評価すると、4つのうち3つのデータセットにおいて、Sharpness-Aware Minimizationによって学習されたモデルを事後的に性能改善できたそうです。一般の学習済み深層学習モデルの汎化性能を向上させられる最適化手法という点で非常に興味深いと感じました。摂動のハイパーパラメータを訓練データとテストデータのミニバッチ損失最小解までの距離から決定されていましたが、個人的にはこのパラメータがデータセットごとにどの程度変わるかという点にも興味を持ちました。 [IS3-73] 布生地の風合いの画像認識 鈴木大智、相澤清晴(東京大学) ECサイトで衣服を販売する際の課題として衣服に使用されている生地の風合いを伝えることが困難であるという点が挙げられます。衣服の手触りに関する先行研究では手触りとの相関が強い物理特性として、生地の厚さ、柔らかさ、粗さが挙げられているそうです。こちらの研究ではKES(Kawabata Evaluation System)と呼ばれる計測機器を用いて生地の物理特性を測定し、衣服の表面画像から生地の厚さ、柔らかさ、粗さを予測モデルを構築するためのデータセットが提案されています。個人的には衣服の表面画像から計測に長時間を要する衣服の物理特性を予測できるという点が非常に興味深いと思いました。将来的には手触りの情報と仮想試着技術を組み合わせることにより、より実店舗に近いユーザ体験を実現できるかもしれませんね。 [OL3A-3] Database-adaptive transfer learning for question answering-based re-ranking in cross-modal retrieval Rintaro Yanagi, Ren Togo, Takahiro Ogawa, Miki Haseyama (北海道大学) ECサイトにおいてキーワード検索の精度はユーザー体験を左右する重要な要素です。画像のキャプションと検索キーワードのテキストベースのマッチンングによるアプローチでは、検索対象の画像へのキャプション付与が必須であるという課題があります。近年ではキャプション付与のコストを軽減するアプローチとして画像と検索キーワードを同一空間に埋め込む手法が提案されていますが、類似画像や曖昧な検索キーワードに対する頑健性には改善の余地があります。こちらの研究では、システムがユーザーに対して質問を生成することにより、検索キーワードに含まれる情報を対話的に補完するシステムが提案されています。ZOZOTOWNの商品検索においても、システムがユーザーの意図を汲み取って対話的に商品検索する仕組みを導入すれば、より良いユーザー体験を実現できるかもしれません。 オフライン参加によって得られた気付き 初日のチュートリアルでは、Transformer誕生の歴史や複数画像から3次元自由視点画像を生成する技術など、近年のコンピュータービジョン分野のトレンドを俯瞰的に知ることができて大変勉強になりました。MIRU2022に投稿された論文はCLIP、自己教師あり学習、ドメイン適用、NeRFがキーワードになる研究が多かったように感じました。インタラクティブセッションでは著者とのディスカッションを通じて弊社のサービスに活かせそうなアイデアを得ることもできました。MIRU2022では様々な研究者や学生の方々と直接オフラインで議論する機会も沢山あり、個人的にはオンライン参加よりも実りの多い時間を過ごせたと思います。 最後に ZOZO NEXTでは次々に登場する新しい技術を使いこなし、プロダクトの品質を改善できるエンジニアを募集しています。ご興味のある方は、以下のリンクからぜひご応募ください。 zozonext.com おまけ 学会の合間に明石焼き風たこ焼きを食べに行きました。 ご満悦。
アバター
こんにちは。ECプラットフォーム基盤SREブロックの高塚と巣立( @tmrekk_ )です。 ZOZOTOWN はクラウド化・マイクロサービス化を進める中で、監視SaaSの Datadog を採用しました。この数年で多くの知見が蓄積され、今では様々なシーンでDatadogを活用しています。この記事ではそのノウハウを惜しみなく公開します。 ※本記事は、先日開催された Datadog Japan Meetup 2022 Summer にて発表した内容を書き起こして再構成したものです。 当日の発表資料 speakerdeck.com 目次 当日の発表資料 目次 はじめに マイクロサービス基盤に必要な監視の要件 第1部 ZOZOTOWNにおけるDatadogの活用 1. どこで障害が起こっているのか分からない → APM 2. アラートやダッシュボードや外形監視が欲しい → Monitors, Dashboards, Synthetics 3. AWSのメトリクスがDatadogに届くのが遅い → Metric Streams 4. 障害調査や負荷試験などでメトリクスを記録するのが大変 → Notebook 5. サイトの健全性を数値化したい → SLO 6. CI/CDが遅い原因がわからない → CI Visibility 第2部 全社管理者の取り組み 1. Azure ADによるDatadogユーザーの管理 2. Multiple-Organization Accountsを使った一括請求 3. カスタムロールを使ったUsageの確認 おわりに はじめに (高塚)こんばんは。これからZOZOTOWNにおけるDatadogの活用と、それを支える全社管理者の取り組みについて発表させていただきます。 わたくし、株式会社ZOZOでSREをしている高塚大暉と申します。よろしくお願いいたします。 (巣立)同じくSREの巣立健太郎です。Datadogの全社管理者も務めています。よろしくお願いします。 (高塚)ZOZOTOWNは日本最大級のファッションECサイトです。洋服はもちろん、シューズやコスメも取り扱っておりますので、ぜひ使ってみてください。 そのZOZOTOWNは2004年にサービスを開始しました。 ここで皆様にクイズです。2004年のできごとは4つのうちどれでしょうか? (巣立)答えは...全部です! 皆様ご正解です。おめでとうございます! 話を本題に戻すと、2004年にオープンしたZOZOTOWNは、長らくオンプレでモノリスなアプリケーションとして動いてきました。ただ、サービスが成長にするにつれて、スケーリングや開発効率など、様々な点で厳しくなってきました。 そこで 2020年から本格的にクラウド化・マイクロサービス化によるリプレイスを推し進めており、今もその道半ばです。 ( 目次に戻る ) マイクロサービス基盤に必要な監視の要件 現在のアーキテクチャがこちらです。AWS, GCP, オンプレのハイブリッド構成で、 API Gateway が各マイクロサービスにルーティングを行います。 まだマイクロサービス化できていないAPIは、API Gatewayを経由してオンプレを叩きます。マイクロサービス化ができたPathは、API Gatewayで新しいAPIにルーティングを切り替えます。 また、BFF( Backend for Frontend )で複数のAPIのレスポンスをまとめて返すこともあります。APIが他のAPIを叩くこともあります。 そして、各APIで使用技術は大きく異なります。 ここまでをまとめると、 マイクロサービスアーキテクチャにおいてサイト全体の信頼性を上げるには、各マイクロサービスを個別に監視するのではなく、統合的に監視することが必須です。 そのためには、我々は色々な言語やフレームワークを使っていますので、 幅広い技術に対応していて、ひと通りの機能が揃った監視SaaSが必要というわけです。 そこでDatadogです。Datadogは、その条件にマッチしていました。 本発表は2部に分かれています。 第1部はZOZOTOWNにおけるDatadogの活用についてです。我々がぶつかった監視の課題と、それをDatadogでどう解決したか、そしてDatadogの便利なポイントについて話します。 第2部はそれを支える全社管理者の取り組みです。弊社ではSREだけでなく色々な職種のエンジニアがDatadogを使っていますので、それを支える工夫をいくつか紹介します。 ( 目次に戻る ) 第1部 ZOZOTOWNにおけるDatadogの活用 (高塚)第1部では、監視の課題を6つ紹介します。 1. どこで障害が起こっているのか分からない → APM 1つ目の課題は、どこで障害が起こっているか分からないということです。 例えばZOZOTOWNアプリのホーム画面を開いたとき、図の赤い矢印のような通信が発生します。ホーム画面の表示がいつもより遅いというとき、 いろいろなマイクロサービスが原因として考えられる わけです。 仮に、どうやら商品APIのレイテンシが上昇しているぞと分かっても、実は商品API自体は問題なくて、商品APIが呼び出す推薦APIが遅くなっていたということがあり得るわけですね。 API間の通信が複雑になればなるほど、障害箇所の特定が難しくなります。 そこで活用したいのがAPMです。APMは色々なことができますが、今日は特に分散トレーシングについて話します。 導入方法は簡単で、スライドにGo言語での例を入れていますが、パッケージを入れた上でアプリケーションコードに数行追加するだけです。もちろん色々な言語に対応しています。 我々は全マイクロサービスにAPMを入れました。さらに、全てのリクエストの入口であるAPI Gatewayで、クライアントとリクエストPathをトレースのタグに追加しました。独自のタグを追加するのも1行のコードで可能です。 そうした結果、ご覧のようなトレース一覧が見られるようになります。この画面ではスライド左側に書いたような条件で絞り込み表示できます。 例えばステータスコードを500番台に絞って、「あれ、このリクエストPathだけエラーが出ているな」とか「iOSだけエラーが起きているな」とか「AWSのAZ-1cだけ障害が起きているな」というような使い方をしています。 これがとても便利なので、我々のチームは全てのマイクロサービスで、かつ本番環境だけでなく事前環境や開発環境も100%トレースを送信しています。 一覧画面の次は、1つ1つのトレースを見ていきましょう。これがアプリのホーム画面を開いたときの実際のトレースです。 上に見える青色のラインがBFFのスパンです。BFFは色々なAPIを叩いて結果を集約し、ホーム画面の描画に必要なJSONを返すという処理をします。 真ん中の緑色のラインが、叩かれている各APIのスパンです。各緑色のスパンの下に紫色のスパンもあるのですが、これがデータベースへの接続のスパンです。 今こうやってぱっと見ても、 ちょうど真ん中らへんのスパンだけ長い、つまり時間がかかっていることが分かりますよね。 「ホーム画面の表示が遅い」という問題に対して、かなりスピーディーに原因を特定することができました。 今度は別のトレースで、細かいスパンの中まで見てみます。 アプリケーションが実行したMySQLのクエリ1つ1つが、トレースの1番下の行、赤色のスパンで表示されています。画面の下半分には、ロック待ちでタイムアウトしたというエラーが表示されています。非常に細かいレベルまで見られるので便利です。 ( 目次に戻る ) 2. アラートやダッシュボードや外形監視が欲しい → Monitors, Dashboards, Synthetics (巣立)課題2では、Datadogの基本機能であるMonitorやDashboard、Syntheticsについて、便利なポイントをお話します。 まず、Monitorです。Monitorはタグをつけることが可能で、マイクロサービスや環境ごとに管理することができます。 スライドの画像では、serviceタグにzozo-search-apiを、envタグにdevを指定してMonitor一覧を表示しています。このようにタグを使ってフィルターできるので、Monitorの一覧表を別に用意する必要がありません。 また、Monitorで使用するクエリやメッセージにもタグを使用することができます。例えばAWS ALBのMonitorを作成する時には、ALB名ではなくタグを使用してMonitorの設定が可能です。 マルチアラート変数を使えば、タグの値ごとに通知することもできます。スライドの画像ではマルチアラート変数にKubernetesのNamespaceを指定しています。 Namespaceの数だけmonitorを作らずとも、全てのNamespaceに対して一括でMonitorを作成できます。 続いてDashboardです。弊チームではマイクロサービスごとにDashboardを作成しています。リリースや負荷試験などの日々の監視や障害対応にとても活躍しています。 今度はSyntheticsについてです。DatadogのSyntheticsは簡単に設定でき、他のサービスに比べても高機能な印象です。 そして、これが一番お気に入りなのですが、 Syntheticsの画面からAPMトレースを見ることができるので、Syntheticsがエラーになった原因をすぐに調べることができます。 ( 目次に戻る ) 3. AWSのメトリクスがDatadogに届くのが遅い → Metric Streams (高塚)課題3は、AWSのメトリクスがDatadogに届くのが遅い件です。 AWSのメトリクスがDatadogに届くまで10分程度遅れることがあります。右側にALBのグラフがありますが、各グラフの右端だけ線が欠けています。 何が問題かと言うと、 Datadog Monitorが10分遅れると、障害に気づくのも10分遅れてしまう という点です。 そこで、Metric Streamsです。これはAWSの場合に限った話になりますが、CloudWatch Metric StreamsとKinesis Data Firehoseを使ってDatadogにメトリクスをストリーミングすることで、先ほどの 遅延を2〜3分程度に抑えることができます。 これを活用することで、我々はMonitorやDashboardの遅延をほぼ考慮することなく利用できています。 ( 目次に戻る ) 4. 障害調査や負荷試験などでメトリクスを記録するのが大変 → Notebook 4つ目の課題は、障害調査や負荷試験などでメトリクスを記録するのが大変、という点です。 リクエスト数や負荷状況などのメトリクスを記録したいというときに、 我々はこれまでDatadogのDashboardをスクショして、社内Wikiにペーストしていました。 時間のかかる面倒くさい作業でした。 それを改善できるのが、Notebookです。NotebookはDatadog上にドキュメントを作成、共有できる機能です。 これが実際に作ったNotebookです。画面はDashboardに似ていて、例えば右上にタイムフレームがあります。 スクショとは違って、この時刻を変更すればグラフが動的に変わります。 デフォルトを障害発生時刻にする、みたいなこともできます。 コメント機能や同時編集機能もあります。 テンプレート変数を使って、左上のプルダウンメニューから環境やマイクロサービスを切り替えて表示できるようにしたり、Notebookをテンプレートとして保存したりすることもできます。これまでのスクショの苦労がなくなりました。 ( 目次に戻る ) 5. サイトの健全性を数値化したい → SLO 課題5は、サイトの健全性を数値化したいということです。これまで色々な監視の話をしましたが、結局サイト全体の健全性はどうなんだ、数値化できないのか、という話です。 そんなときにSLOが使えます。SLOとは、スライドに記載したようなサービスレベル目標のことですが、Datadogを使うと簡単にSLOを算出して可視化できます。 これが試しにやってみたサンプルです。 障害を防ぐという仕事は「やって当たり前」と思われることも多く、エンジニアでさえ成果が見えにくい部分です。それをSLOで可視化したり、目標を設定したりすることで、モチベーションが湧きやすくなります。 ( 目次に戻る ) 6. CI/CDが遅い原因がわからない → CI Visibility 6つ目の課題は、CI/CDが遅い原因が分からないということです。 我々はCI/CDにGitHub Actionsを使っています。リポジトリが大きくなるにつれて、WorkflowやJobにかかる時間が伸びたり、Workflowの数自体が増えたりして、CI/CDの待ち時間、デプロイ待ちのような時間が生まれてしまいました。 ただ、 WorkflowやJobが多く、どれを改善すれば良いのか分からないという問題がありました。 そこでCI Visibilityです。CI Visibilityは多くのCIサービスに対応しています。 導入も非常に簡単で、例えばGitHub Actionsの場合は、DatadogのIntegrationsの画面からGitHub Appをインストールだけで完了します。リポジトリを指定することも可能です。 導入すると表示されるようになるのがこの画面で、これはCIの実行結果一覧です。APMと同じように、リポジトリ名やブランチ名でフィルター表示できます。 そして、1つの実行結果をクリックすると、なんとトレースが見られます。例えばこのトレースでは、最初に同じJobが複数環境に対して並列に実行されて、そのあといくつか時間のかかるJobが実行されています。 ただ、1回の実行結果を見るよりも、複数回の実行結果を横断的に分析したほうが良いと思いまして... CIのDashboardを作ってみました。色々ありますが、例えば下半分の表は、もっとも時間のかかったWorkflowのランキングです。画面外にはJob単位の表もあります。 これを見ることで、 どのWorkflowやJobに時間がかかっているのか、どれくらいの頻度で実行されているのかを分析できるようになりました。 もう1つ我々がやっている工夫として、実行時間×単価でCIのおよその料金を表示するようにしました。このJobにいくらお金がかかっている、ということを可視化しています。 先ほどと同じ話になりますが、 可視化することで改善のモチベーションが湧くことを実感しています。 以上が第1部です。ご覧のサービスの活用法をご紹介しました。1つでも皆さまにとってお役に立つ情報が含まれていたら幸いです。 ( 目次に戻る ) 第2部 全社管理者の取り組み (巣立)第2部では、ZOZOにおける全社管理者の役割を最初に説明します。その後、具体的な取り組みを3つ紹介します。 弊社では、Datadogやその他のクラウドサービスをたくさんのチームが利用しています。 今回のテーマである全社管理者とは、それらのクラウドサービスのアカウント管理やセキュリティインシデントの監視など、サービス利用者が開発に集中できるようにするための様々な取り組みを行うメンバーです。複数チームのSREから構成されており、Datadogの管理者は現在3名います。 ( 目次に戻る ) 1. Azure ADによるDatadogユーザーの管理 ここから、その全社管理者の取り組みについて紹介します。1つ目はAzure ADによるDatadogユーザの管理についてです。 通常であれば、クラウドサービスごとにアカウントの管理が必要になります。また、Datadogに関して言えば、複数のOrganizationに所属しているメンバーは、Organizationを切り替えるたびにIDやパスワードの入力が必要になります。 そこでAzure ADです。Azure ADでは、様々なクラウドサービスのアカウント管理を一元化し、SSO (Single Sign-on) を実現することができます。Azure ADはDatadogにも対応しています。 Azure ADを使ってDatadogのアカウントを管理するようにし、SSOを実現しました。 これによりOrganizationの切り替えの際にID・パスワードの入力が不要になり、利用者も管理者もアカウント管理の負担が減りました。 ( 目次に戻る ) 2. Multiple-Organization Accountsを使った一括請求 続いて、Multiple-Organizationを使ったDatadogの一括請求についてです。 弊社では、Datadogで複数のOrganizationを利用しています。そのため、Organization毎に請求が発生し、Organizationの数だけ請求書を処理する必要がありました。 そこでMulti-Organizationです。 下の画像のようにOrganizationごとの利用量、例えばInfra hostsがこのくらい、などを親Organizationから確認することが可能になります。そして、全てのOrganizationの請求を親Organizationに一元化できます。 この機能はDatadogサポートへ連絡することで使えるようになります。 Multi-Organizationを利用したことによって、 Organizationごとに請求書を処理する必要がなくなりました。 また、親Organizationから子Organizationの利用状況の確認が可能となりました。 そして、無料枠を全てのOrganizationで合算して計算するようになったため、無料枠の利用が最適化されました。 ( 目次に戻る ) 3. カスタムロールを使ったUsageの確認 3つ目に、カスタムロールを使ったUsageの確認です。 DatadogのUsage、先ほど見せたInfra hostsがこのくらいなどを確認できる画面ですね。このUsageは、Admin Roleを持ったユーザしか表示することができません。 しかし、Admin Roleを持ったユーザ、つまりはDatadogの管理者は、全てのOrganizationでどのくらい利用しているのかを把握することが難しいので、このOrganizationでのこの機能の利用料は適切かどうかを判断することができません。 そこでカスタムロールを使用することにしました。 カスタムロールを説明する前に簡単にDatadogのデフォルトのロールについて説明します。1つずつの説明は省きますが、 デフォルトのロールで請求情報、UsageにアクセスできるのはAdmin Roleだけです。 では、本題のカスタムロールについてです。 カスタムロールでは任意のアクセス許可を定義することができます。そのため、デフォルトのロールよりも適切なアクセス許可をユーザに付与することができます。また、ユーザが複数のロールを持つ場合、例えばデフォルトのロールとカスタムロールの2つを持つ場合は、両方のロールに付与されている全てのアクセス許可が付与されます。 このカスタムロールに請求情報へのアクセス権を付与し、全てのユーザにこのロールを渡すことで、 誰でも自身の所属するOrganizationのUsageの確認が可能となりました。 また、管理者は各Organizationの利用料が適切かどうかをOrganizationの利用者に任せることが可能となりました。 以上が、Datadog全社管理者の取り組みについての紹介でした。 まとめです。本発表では、ZOZOTOWNにおけるDatadogの活用と、それを支える全社管理者の取り組みについてご紹介しました。 ( 目次に戻る ) おわりに 冒頭でご紹介した通り、本内容は Datadog Japan Meetup 2022 Summer で発表させていただきました。 ご来場いただいた皆様、ここまでお読みいただいた皆様、誠にありがとうございました。 私たちのチームでは、Datadog・AWS・Kubernetes・Istioなどをフル活用し、ZOZOTOWNのマイクロサービス基盤を一緒に作る仲間を募集しています。ぜひ下記リンクからご応募ください! hrmos.co
アバター
はじめに ZOZOTOWN開発本部ZOZOTOWNアプリ部Android2ブロックの鈴木( @s1u2z1u3ki )です。 本投稿ではZOZOTOWN Androidアプリを、Material Designに準拠したUI/UX 1 とするために取り組んでいる内容を紹介します。 目次 はじめに 目次 Material Designとは? Material Design勉強会について 勉強会の流れ 存在した課題 課題解決へのアプローチ 提案会の実施 提案会の流れ 1. 提案会の準備 2. セクションの復習 3. 提案内容の議論 実装会の実施 「結局やらない」をなくすため モブプロ形式でリリースまでのスピードを上げるため 実装会の流れ 1. タスクの共有 2. タスクを進める 3. 進捗記入 4. 進捗共有 取り組みの結果 リリースした提案 1. ログイン画面のテキストフィールドのフォーカスを強調する 2. 検索結果のFABと商品件数にFadeアニメーションを追加する 3. 商品の詳細画面から戻る際のアニメーションのDurationを調整する メンバーの感想 良かった点 改善点 取り組みは課題解決に繋がっているか? まとめ 最後に Material Designとは? Material Design は2014年にGoogleが提唱したデザインシステムです。Android、Flutter、Web向けの高品質な体験を構築する手助けをしてくれます。 Androidアプリは、Androidプラットフォームや他アプリと一貫性のある表示・操作を提供するため、Material Designに準拠することが推奨されています。 2 例えばリストのアイテムを横スワイプで削除する機能は、AndroidプラットフォームやGoogle製のアプリで共通の操作であり、ガイドラインにも記載があります。ガイドラインに準拠しないと、AndroidプラットフォームやGoogle製のアプリと操作方法が異なり、アプリ操作中にユーザがストレスを抱える可能性が高くなります。 さらに Material Design Components と呼ばれるライブラリが提供されており、ガイドラインを実装したUIコンポーネントが提供されています。 また、2021年にはMaterial Design 3の発表があり、パーソナライズの強化が行われたMaterial Youの登場などアップデートが続いています。 Material Design勉強会について ZOZOTOWN AndroidアプリにはMaterial Designに準拠していない箇所が存在し、さらにデザイナーとエンジニア両者がMaterial Designの知識に乏しい状況でした。 上記を理由に、ZOZOTOWNに関わるAndroidエンジニアとデザイナーを中心に、下記2つを目的として「Material Design勉強会」を実施してきました。 3 Material Designについて理解を深める Material Designに準拠したZOZOTOWN Androidアプリを作る 勉強会の流れ 勉強会は、持ち回りで決めた担当者がセクション(e.g. Motion , Interaction )の内容を和訳・要約した資料を準備します。 資料をもとに、担当者が講義形式で内容を紹介します。ZOZOTOWNに関わるAndroidエンジニアとデザイナーが参加し、質問や感想を出し合いながら理解を深めていきます。 勉強会資料の例 この勉強会のおかげでデザイナーとエンジニアがMaterial Designに関する知識をもとに、UI/UXに対する提案・議論が可能になるなど、勉強会の内容をプロダクトに反映できつつありました。 UI/UXに関する提案例 存在した課題 前述の通り、勉強会のおかげで、新規の画面や改修が入る画面については、Material Designに準拠したUI/UXとなりつつありました。 しかし、 既存の画面についてはMaterial Designへの準拠が進んでおらず、さらなるUI/UXの向上が見込めました。 課題解決へのアプローチ 前述の課題を解決するために、勉強会の他に「提案会」と「実装会」を新たに実施することにしました。 課題解決のアプローチ それぞれ取り組みの詳細を説明していきます。 提案会の実施 デザイナーとエンジニアで、どの箇所が改善可能なのか認識を合わせなければ改善が進まないと判断し、月1回で1時間程度の提案会を実施することにしました。 Material Designに準拠できるように、改善できる箇所の認識合わせを目的としました。 提案会の流れ 提案会は下記のように進行します。 提案会の流れ 1. 提案会の準備 提案会までに、担当者が担当するセクション(e.g. Motion , Interaction )について、プロダクトのどの箇所に改善の余地があるかを考えます。 デザイナーとエンジニア両者の視点を提案に含めるため、担当者を2名にし、なるべくデザイナーとエンジニアがペアとなるようにしました。 下記の項目を埋めて提案資料を作成します。 項目 記述内容 内容 提案内容のサマリーを記載する。 スクリーンショット 提案内容を実装した際のスクリーンショットや動画などを記載する。 ドキュメント該当箇所 Material Designのドキュメント該当箇所のリンクを記載する。 提案書の例 2. セクションの復習 実際の提案会では、担当者が、会で取り扱うセクションの概要を説明します。 これは、勉強会でセクションを学んでから時間が経過していたり、最近入社した方は勉強会で学んでいない可能性が高いために行っています。 基本的には、 勉強会のときに用いた資料 の要点を説明していきます。 これにより、どのような背景の提案なのかが掴みやすくなり、のちの提案内容の理解が容易になります。 3. 提案内容の議論 担当者が提案内容を説明し、実装着手OKな提案なのかを決めるために議論をします。 下記のような観点を総合的に判断し、結論を出します。 デザイン意図に反していないか 既存デザインとの調和を乱さないか 工数がかかり過ぎないか 実装会の実施 月1回で1時間程度エンジニアの時間を確保し、提案会で実装着手OKとなったものを実装する会を開催することにしました。 この会を始めた理由は下記の2点です。 「結局やらない」をなくすため モブプロ形式でリリースまでのスピードを上げるため 「結局やらない」をなくすため ZOZOTOWN Androidブロックでは案件が複数並行して動いています。 そのため、着手OKとなった提案の実装が忘れられてしまう可能性がありました。 「実装会」という名目で時間をあらかじめ確保することで、やらずに放置され続けるというのを防止する狙いがありました。 モブプロ形式でリリースまでのスピードを上げるため モブプロ形式で実装を進めることで、コードを書きながら詳細な実装方針を決めることができ、レビューがほとんど必要ないというメリットがあります。 そのため、スピード感を持ってリリースできると予想できました。 実装会の流れ ZOZOTOWNのAndroidエンジニアは10名以上いるため、実装会内だけのチームを作り実装を進めます。 実装会の流れ 1. タスクの共有 タスクは下図の通りKanbanボードで管理しており、提案会で着手OKとなったものをバックログとして積んでいます。 実装会で使用しているKanbanボード バックログの中から、着手するタスクを決めて共有したり、タスクの進捗を共有したりします。 2. タスクを進める 各チームごとモブプロの役割を決めて、タスクを進めます。 ドライバー(タイピスト) 人数: 1人 やること: モブの意見をもとに実装をする ナビゲーター(モブ) 人数: タイピスト以外 やること: ドライバーに実装して欲しいことを伝える 3. 進捗記入 実装会は基本的には月1回で行っているため、前回の内容を思い出せるように、進捗を記載します。 進捗記載の例 4. 進捗共有 最後に各チームが集まり進捗を共有します。 記入した内容を共有し、成果物がある場合はそれも共有します。 取り組みの結果 提案会を7回、実装会を6回実施して下記のような結果となりました。 4 提案: 46件 着手OKとなった提案: 15件 リリース実績: 3件 リリースした提案 提案会・実装会を経てリリースした提案を紹介します。 1. ログイン画面のテキストフィールドのフォーカスを強調する ログイン画面において、テキストフィールドのフォーカスを強調する対応をしました。 ログイン画面 テキストフィールドのフォーカス強調 (左: Before, 右: After) 提案の背景は、ドキュメントの States に、下記の記述があったためです。 States communicate the status of UI elements to the user. Each state should be visually similar and not drastically alter a component, but must have clear affordances that distinguish it from other states and the surrounding layout. つまり、UIの状態はユーザに伝えるべきものであり、ユーザの行動を促すヒントを含んでいる必要がありました。 対応前は、フォーカスの状態をカーソルのみでユーザに伝えていました。 テキストフィールドの枠を色付けることで、ユーザにフォーカスの状態が伝わりやすくなったかと思います。 2. 検索結果のFABと商品件数にFadeアニメーションを追加する 商品の検索結果が表示される画面の、FAB(Floating Action Button)と商品件数にアニメーションをつける対応をしました。 FABは画面右側からスライドしてくる仕様のため、少しわかりにくいですが、対応後はフェードインしつつスケールを大きくするアニメーションとしています。 ガイドラインの例 に沿って MaterialFade を使用しています。 検索結果画面 FAB (左: Before, 右: After) 商品件数の表示・非表示は、デザイナーの要望もあり、 Animator を使用しフェードのみのアニメーションにしています。 検索結果画面 商品件数 (左: Before, 右: After) 提案の背景は、ドキュメントの Motion に、下記の記述があったためです。 Motion celebrates moments in user journeys, adds character to common interactions, and can express a brand’s style. つまり、Motionを使用することでページへのアクセスを歓迎し、インタラクションに個性を与えることが可能になります。 対応前はアニメーションがありませんでしたが、フェードアニメーションを加えることで、よりスタイリッシュな見栄えになったかと思います。 3. 商品の詳細画面から戻る際のアニメーションのDurationを調整する 変化は小さいですが、商品の詳細画面から戻る際のアニメーションのDurationを短縮しました。 商品詳細から戻る際のDuration (左: Before, 右: After) 提案の背景は、ドキュメントの Motion に、下記の記述があったためです。 Transitions that close, dismiss, or collapse an element use shorter durations. Exit transitions may be faster because they require less attention than the user’s next task. つまり閉じる遷移は、開く遷移と比較してユーザの注意を集める必要がなく、高速にする必要があります。 対応前は、商品の詳細画面を開くとき・閉じるときもDurationは300msとしていました。 ドキュメントに例があるように、閉じる際は250msに変更しました。 メンバーの感想 提案会と実装会に参加しているメンバーから以下のような感想が集まりました。 良かった点 あとは実装会というのを設けることで検討したけど今案件やってるから..となって一生やらないということが無くなるのもメリットかなと思います デザイナーの手が足りず、どうしてもiOSのデザインを調整しただけになりがちだったAndroidデザインが、少しずつでもAndroidらしい姿に変わっていけているところが良い点です。 単にUI/UX改善について議論するということではなく、デザインガイドラインについてみんなで一緒にインプットしそれをベースとすることで「ガイドラインではこうだから」みたいな共通の基準がある状態で議論出来るので、そこがいいなと感じています。 理想は理想のままで終わりがちな物事が多い中、勉強会をやって終わりではなく、提案、議論、そしてプロダクトへの導入まで行うことができたことは非常に良い実践例になったと思います。 改善点 会自体ではないのですが、OS差分が増えていく事になるので実装箇所が増えていった時にちゃんと把握しておきたいなーと思いました(他チームから挙動が違うと言われた時にすぐ答えられるように) 1時間で実装するのは時間的に苦しそうだなと感じました。実装箇所をピンポイントに絞ったり、後でプロダクトに導入するためのコンポーネントを作成するだけに留めるなど工夫できることがあるかもしれません。 できればもう少しダイナミックに変化が見えるような大きい改修点を見つけられると良いですね。 他の勉強会にも言えることですが、開催済みの勉強会内容を新しく入社されてくる方々にキャッチアップしていただく方法は、これからも模索していかないとなと感じています。 取り組みは課題解決に繋がっているか? 提案会と実装会の取り組みは「既存の画面をMaterial Designに準拠したUI/UXへ改善する」ことに繋がっているのでしょうか? 改善点はあるが「繋がっている」と考えています。 取り組みの結果 にも記述した通り、デザイナーと認識を合わせ、着手OKとなった提案が15件あり、そのうち3件はリリースできているからです。 さらにメンバーの感想から業務でUI/UXについて再考するきっかけとなっていたり、案件をやりつつも実装を進められたりと、取り組み全体としてポジティブな意見をいただけたからです。 メンバーからの感想を踏まえて、今後は下記の内容を改善しつつ取り組みを続けていければと考えています。 実装会からリリースまでのスピードを上げる 大きくインパクトのある提案とリリース まとめ 既存画面をMaterial Designに準拠したUI/UXへ改善するための具体的なプロセスを紹介しました。 今後は、Material Design 3の内容についても同様にプロダクトに取り入れていければと思います。 最後に ZOZOではAndroidエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください。 hrmos.co プロダクトのデザイン思想としてガイドラインへの準拠が難しい箇所については、ガイドラインの本質を理解した上で、プロダクトとしてのデザインを尊重するケースもあります。 ↩ Why We Recommend Material Components for Android において詳細が説明されています。 ↩ 勉強会は2019年4月からスタートし、現在も実施しています。 ↩ 2022年7月11日現在 ↩
アバター
こんにちは。WEAR部Androidチームの御立田です。先日、WEARチームでコーディネート動画を投稿できる機能を追加しました。 その際、WEARが提供する音楽リストから、ユーザーが好きな音楽を選択する機能を実装する必要がありました。今回は、動画ファイルの音楽データの変更をAndroidの端末上で行ったのでそこで得られた知見を共有したいと思います。 動画ファイル、音楽ファイルのフォーマットや、エンコード、デコードの設定は多岐にわたります。本投稿では、シンプルなパターンでやや抽象的に説明し、この投稿を読んだ人が「Androidにおいて動画の変換する時に何を調べれば良いのかがわかるようになる」を目的としています。 変換の仕様 任意の動画ファイルの音楽データを、任意の音楽ファイルのデータに差し替える 動画は指定のフォーマットで再エンコードする 音楽は元の音楽データをそのまま利用する ただし、動画の長さの方が短い場合、音楽も動画の長さまでとする Android端末上で動画を変換する流れ MediaMetadataRetrieverで動画ファイルの長さを取得する 出力用の動画データを作成する MediaExtractorで動画ファイルのデータ(エンコードされたままの状態)を読み込む MediaCodec(decoder)でエンコードされているデータをデコードする MediaCodec(encoder)でデコードされているデータを、指定のフォーマットにエンコードする 出力用の音楽データを作成する MediaExtractorで音楽ファイルのデータ(エンコードされたままの状態)を読み込む MediaMuxerを利用して、動画と音楽(2と3の出力)を混ぜ合わせて新しい動画ファイルを出力する ※エンコード、デコード処理に、 Surface を利用することでより高速な変換が可能です。 流れとしては上記の通り、非常にシンプルです(実際の実装では2〜4はメモリの効率的な利用のために少しずつ読み込んで何度も繰り返しますが、わかりやすさ優先で各処理ごとに分けて説明しています)。 しかし、それでもMediaMuxerへのデータの渡し方にクセがあったり、エンコードやデコードの処理にクセがあったりしてなかなか一筋縄ではいきませんでした。 以降、各項目について疑似コードで説明していきますが、クセのある部分については多少詳しく説明していきたいと思います。 1. MediaMetadataRetrieverで動画ファイルの長さを取得する MediaMetadataRetrieverを利用して動画ファイルの長さを読み込みます。特に難しいことはありません。音楽ファイルを動画の長さと合わせるために、後で設定します。 val retriever = MediaMetadataRetriever().apply { setDataSource(inputVideoFileDescriptor) } val key = MediaMetadataRetriever.METADATA_KEY_DURATION val videoDurationMs = retriever.extractMetadata(key)?.toLong() 2. 出力用の動画データを作成する MediaExtractorで動画ファイルのデータ(エンコードされたままの状態)を読み込む MediaExtractorを利用して、動画ファイルのデータを読み込みます。大まかな流れは下記の通りです。 1フレームの動画ファイルのデータを受け取れるByteBufferと、読み込んだByteBufferの状態を表すByteBufferInfoを準備する ByteBufferに1フレーム読み込む 読み込んだフレームの状態に応じてByteBufferInfoを設定する 最終フレームまで2〜3を繰り返す 取得したByteBufferやByteBufferInfoは次のデコーダーで利用します。 まずは動画ファイルのデータをExtractorで読み込めるようにExtractorの設定をします。 // 動画ファイルのtrackをextractorに設定する // (動画のvideoFormatやmimeも後で利用するので取得しておく) var trackIndex = 0 val videoTrackIndex: Int val videoFormat: MediaFormat val mime: String while ( true ) { val currentFormat = videoExtractor.getTrackFormat(trackIndex) val currentMime = currentFormat.getString(MediaFormat.KEY_MIME) ?: continue if (currentMime.startsWith( "video/" )) { videoTrackIndex = trackIndex videoFormat = currentFormat mime = currentMime break } trackIndex ++ if (trackIndex >= videoExtractor.trackCount){ error( "video track がありません" ) } } videoExtractor.selectTrack(videoTrackIndex) // 動画ファイルの1フレームのデータを読み込む val sampleDataOutputByteBuffer = ByteBuffer.allocate(SAMPLE_DATA_BUFFER_CAPACITY) val sampleDataOutputByteBufferInfo: BufferInfo val readSampleDataSize = videoExtractor.readSampleData(sampleDataOutputByteBuffer, 0 ) val isEmptySampleData = readSampleDataSize < 0 if (isEmptySampleData) { // 最後まで読み込んだ場合は、BufferInfoを終了状態を表す内容に設定する。 sampleDataOutputByteBufferInfo = BufferInfo().apply { offset = 0 size = 0 presentationTimeUs = 0 flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM } } else { // 読み込んだsampleDataの状態を、BufferInfoに設定する // offset: 今回は利用しないので0 // size: sampleDataのサイズ // presentationTimeUs: extractorから現在のsampleDataの終了時間を取得して設定 // frags: keyFrameかどうかのフラグを設定 val isKeyFrame = videoExtractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0 val bufferInfoFlags = if (isKeyFrame) MediaCodec.BUFFER_FLAG_KEY_FRAME else 0 sampleDataOutputByteBufferInfo = BufferInfo().apply { offset = 0 size = readSampleDataSize presentationTimeUs = videoExtractor.sampleTime flags = bufferInfoFlags } } // videoExtractorの読み込み位置を次に進めておく videoExtractor.advance() MediaCodec(decoder)でエンコードされているデータをデコードする MediaCodecを利用して、元動画ファイルのデータをデコードします。大まかな流れは下記の通りです。 元動画の形式をデコードできるデコーダーを作成 MediaExtractorで取得した1フレームの情報(ByteBufferとByteBufferInfo)をデコーダーの入力用のキューに入れる デコーダーの出力用キューからデコードされたデータを取得する 2〜3を繰り返す // MediaExtractorで取得した1フレームの情報(ByteBufferとByteBufferInfo)をデコーダーの入力用のキューに入れる val decoder = MediaCodec.createDecoderByType(mime).apply { configure(videoFormat, null , null , 0 ) start() } // decoderの入力用のバッファの準備ができるまで待機。inputQueueのデータが処理されるのを待つ val decoderInputBufferIndex: Int while ( true ) { val inputBufferIndex = decoder.dequeueInputBuffer( 0 ) if (inputBufferIndex >= 0 ) { decoderInputBufferIndex = inputBufferIndex break } delay( 500 .milliseconds) } // 入力用のバッファへデータを書き込んだ後にenqueueを行い、書き込んだデータのdecode処理が行われるようにする val inputBuffer = decoder.getInputBuffer(decoderInputBufferIndex) ?: error { "デコーダー用の input buffer が取得できません" } inputBuffer.clear() inputBuffer.put(sampleDataOutputByteBuffer) decoder.queueInputBuffer( decoderInputBufferIndex, 0 , sampleDataOutputByteBufferInfo.size, sampleDataOutputByteBufferInfo.presentationTimeUs, sampleDataOutputByteBufferInfo.flags, ) // デコーダーの出力用キューからデコードされたデータを取得する val decoderOutputBufferInfo = BufferInfo() val decoderOutputBufferIndex: Int // dequeue の準備ができるまでまつ while ( true ) { val outputBufferIndex = decoder.dequeueOutputBuffer(decoderOutputBufferInfo, 0 ) if (outputBufferIndex > 0 ) { decoderOutputBufferIndex = outputBufferIndex break } delay( 500 .milliseconds) } val decoderOutputBuffer = decoder.getOutputBuffer(decoderOutputBufferIndex) ?: error { "入力ビデオがデコードされたデータが入った ByteBuffer が取得できません" } // output buffer をすぐに解放したいため、bufferをコピーしておく val decodedSrcVideoByteBuffer = ByteBuffer.allocate(decoderOutputBuffer.capacity()).apply { put(decoderOutputBuffer) flip() } decoder.releaseOutputBuffer(decoderOutputBufferIndex, false ) MediaCodec(encoder)でデコードされているデータを、指定のフォーマットにエンコードする MediaCodecを利用して、指定のフォーマットにエンコードします。大まかな流れは下記の通りです。 指定の形式にエンコードできるエンコーダーを作成 decoderの出力をエンコーダーの入力用のキューに入れる エンコーダーの出力用キューからエンコードされたデータを取得する 2〜3を繰り返す // decoderの出力をエンコーダーの入力用のキューに入れる // 出力するビデオのフォーマットを設定し、encoderを作成する val outputFormat = MediaFormat.createVideoFormat( "video/avc" , 1920 , 1080 ).apply { setInteger(MediaFormat.KEY_BIT_RATE, 2 * 1000 * 1000 ) setInteger(MediaFormat.KEY_FRAME_RATE, 30 ) setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 3 ) setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible) } val encoder = MediaCodec.createEncoderByType(mime).apply { configure(outputFormat, null , null , MediaCodec.CONFIGURE_FLAG_ENCODE) start() } // encoderの入力用のバッファの準備ができるまで待機。inputQueueのデータが処理されるのを待つ val encoderInputBufferIndex: Int while ( true ) { val inputBufferIndex = encoder.dequeueInputBuffer( 0 ) if (inputBufferIndex >= 0 ) { encoderInputBufferIndex = inputBufferIndex break } delay( 500 .milliseconds) } // 入力用のバッファへデータを書き込んだ後にenqueueを行い、書き込んだデータのencode処理が行われるようにする val inputBuffer = encoder.getInputBuffer(encoderInputBufferIndex) inputBuffer.clear() inputBuffer.put(srcVideoDecodedByteBuffer) encoder.queueInputBuffer( encoderInputBufferIndex, 0 , srcVideoDecodedByteBufferByteBufferInfo.size, srcVideoDecodedByteBufferByteBufferInfo.presentationTimeUs, srcVideoDecodedByteBufferByteBufferInfo.flags, ) // エンコーダーの出力用キューからエンコードされたデータを取得する val encoderOutputBufferInfo = BufferInfo() val encoderOutputBufferIndex: Int // dequeue の準備ができるまでまつ while ( true ) { val outputBufferIndex = encoder.dequeueOutputBuffer(encoderOutputBufferInfo, 0 ) if (outputBufferIndex > 0 ) { encoderOutputBufferIndex = outputBufferIndex break } delay( 500 .milliseconds) } val encoderOutputBuffer = encoder.getOutputBuffer(encoderOutputBufferIndex) ?: error { "出力ビデオ用の、エンコードされたデータが入った ByteBuffer が取得できません" } // outputBufferをすぐに解放したいため、bufferをコピーしておく val encodedDstVideoByteBuffer = ByteBuffer.allocate(encoderOutputBuffer.capacity()).apply { put(encoderOutputBuffer) flip() } encoder.releaseOutputBuffer(encoderOutputBufferIndex, false ) 3. 出力用の音楽データを作成する MediaExtractorで音楽ファイルのデータ(エンコードされたままの状態)を読み込む // 音楽ファイルのtrackをextractorに設定する // (音楽のaudioFormatやmimeも後で利用するので取得しておく) var trackIndex = 0 val audioTrackIndex: Int val audioFormat: MediaFormat val mime: String while ( true ) { val currentFormat = audioExtractor.getTrackFormat(trackIndex) val currentMime = currentFormat.getString(MediaFormat.KEY_MIME) ?: continue if (currentMime.startsWith( "audio/" )) { audioTrackIndex = trackIndex audioFormat = currentFormat mime = currentMime break } trackIndex ++ if (trackIndex >= audioExtractor.trackCount){ error( "audio track がありません" ) } } audioExtractor.selectTrack(audioTrackIndex) // 音楽ファイルのデータを読み込む val sampleDataOutputByteBuffer = ByteBuffer.allocate(SAMPLE_DATA_BUFFER_CAPACITY) val srcEncodedByteBuffer: ByteBuffer val srcEncodedByteBufferInfo: BufferInfo val readSampleDataSize = audioExtractor.readSampleData(sampleDataOutputByteBuffer, 0 ) val isEmptySampleData = readSampleDataSize < 0 val isOverVideoDuration: Boolean = audioExtractor.sampleTime >= videoDurationMs * 1000 if (isEmptySampleData) { // 最後まで読み込んだ場合は、BufferInfoを終了状態を表す内容に設定する srcEncodedByteBuffer = ByteBuffer.allocate( 0 ) srcEncodedByteBufferInfo = BufferInfo().apply { offset = 0 size = 0 presentationTimeUs = 0 flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM } } else { if (isOverVideoDuration) { // 動画の長さを超えた場合は、BufferInfoを終了状態を表す内容に設定する srcEncodedByteBuffer = ByteBuffer.allocate( 0 ) srcEncodedByteBufferInfo = BufferInfo().apply { offset = 0 size = 0 presentationTimeUs = 0 flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM } } else { srcEncodedByteBuffer = ByteBuffer.allocate(sampleDataOutputByteBuffer.capacity()).apply { put(sampleDataOutputByteBuffer) flip() } val isKeyFrame = audioExtractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0 val bufferInfoFlags = if (isKeyFrame) MediaCodec.BUFFER_FLAG_KEY_FRAME else 0 // 読み込んだsampleDataの状態を、BufferInfoに設定する // offset: 今回は利用しないので 0 // size: sampleDataのサイズ // presentationTimeUs: extractor から現在のsampleDataの終了時間を取得して設定 // frags: key frame かどうかのフラグを設定 srcEncodedByteBufferInfo = BufferInfo().apply { offset = 0 size = readSampleDataSize presentationTimeUs = audioExtractor.sampleTime flags = bufferInfoFlags } } } // 次のsampleに進めておく audioExtractor.advance() 4. MediaMuxerを利用して、動画と音楽(2と3の出力)を混ぜ合わせて新しい動画ファイルを出力する MediaMuxerを作成する エンコーダから取得できるMediaFormatを設定する 音楽ファイルのMediaFormatを設定する MediaMuxerをスタートする MediaMuxerに出力用のデータを書き込む エンコーダから出力された動画データを書き込む MediaExtractorで取得した音楽データを書き込む 2を繰り返す // MediaMuxerを作成する val muxer = MediaMuxer(outputPath, OutputFormat.MUXER_OUTPUT_MPEG_4) // エンコーダから取得できるMediaFormatを設定する // * encoder.outputFormatは、encoderから最初のoutputが行われる直前のタイミングで取得できるようになる val videoTrackIndex = muxer.addTrack(encoder.outputFormat) // 音楽ファイルのMediaFormatを設定する。そのまま利用するので最初に取得したaudioFormatをそのまま設定する val audioTrackIndex = muxer.addTrack(audioFormat) // MediaMuxerをスタートする // * start前に、書き込みを行うトラックが追加されている必要がある。今回の場合は、videoTrackとaudioTrack // * 一度startするとトラックの追加はできない muxer.start() // MediaMuxerに出力用のデータを書き込む // * エンコーダから出力された動画データを書き込む muxer.writeSampleData(videoTrackIndex, videoByteBuffer, videoByteBufferInfo) // * MediaExtractorで取得した音楽データを書き込む muxer.writeSampleData(audioTrackIndex, audioByteBuffer, audioByteBufferInfo) まとめ 以上が動画や音楽のエンコード、デコード、差し替えの流れになります。 流れとしてはシンプルですが、実装にあたっていくつかハマった点があるので、それぞれの工程ごとに列挙します。 動画ファイル、音楽ファイルデータ読み込み時 MediaExtractorのselectTrackを忘れずに実行する selectTrackしなくてもエラーにならず空のデータが取得されるだけの挙動となり、原因究明に時間がかかってしまった エンコード、デコード時 releaseOutputBufferを忘れずに実行する encoder,decoderの結果が出力できないので結果として処理が止まり無限ループに陥ってしまった 差し替え時(MediaMuxer利用時) 動画と音楽を1つのファイルに書き込むときは、事前に全てのデータを書き込める状態にしておく必要がある MediaMuxerを開始する前(start()を呼ぶ前)に、書き込みたい動画と音楽をaddTrackしておかなければならない start()は一度実行したらstop()しても再度start()を呼ぶことはできず1つずつトラックを書き込んでいくことができない 動画や音楽のデータをaddTrackする時に指定するMediaFormatは実際にencoderから取得できるものを利用する必要がある encoderからMediaFormatを取得できるようになるタイミングは最初のエンコード結果が出力される直前 MediaFormatの取得を待ってからmuxerの書き込みを開始させた 自分でencoder作成時に指定したMediaFormat ではない ので注意が必要 encoderから取得できるMediaFormat以外でaddTrackした場合、書き込み正常に行われない これらを知っておくことで、うまく変換できなかった時に「何を調べたら良いのか」の足がかりになると思います。この情報が、動画の音声を任意の別データに差し替えてエンコードしたいと思った人の一助になれれば幸いです。 最後までご覧いただきありがとうございました。ZOZOでは、各種エンジニアを採用中です。ご興味のある方は以下のリンクからご応募ください。 corp.zozo.com
アバター
はじめに こんにちは。ブランドソリューション開発本部 WEAR部 SREの和田( @wadason )です。普段は 「ファッションコーディネートアプリ WEAR」 のSREとしてクラウドの運用やリプレイスをおこなっています。 WEARはサービス開始から10年が経ち、クラウドやオンプレミスを含む大小様々なシステムが稼働しています。アプリケーションを動かすための基盤にはAmazon ECSのようなコンテナを前提としたものから、オンプレミスのAPIやBatchを動かすIISまで幅広く扱っています。そうした中で、約1年前にSREチームが結成され、技術負債の脱却やクラウドを中心としたインフラの運用を行なってきました。当初取り組んでいた大規模なリプレイス案件も落ち着き、チームメンバーが増えてきたので、現在では 分散した技術スタックをKubernetesへ統一するリプレイスプロジェクト を開始しています。 本記事ではWEARにKubernetesを導入した背景や、移行にあたり工夫した事例を紹介します。Kubernetesの導入を検討している方やSREの活動事例を知りたい方に向けて、少しでも参考になれば幸いです。 目次 はじめに 目次 導入の背景 技術負債の脱却による運用負荷の低減 GitOpsによる開発者体験の向上 リプレイス後の構成 EKS内で利用する様々なツール 複数のEKSクラスタを用意する理由 デプロイ 工夫した点 Digdag WorkerにおけるConfigMapの動的管理 Push通知におけるリアルタイム性を意識した構成 振り返り 運用負荷が下がり積極的な改善に集中できる体質に 継続的に改善に取り組むことが大事 今後の展望 アプリケーションの段階的な移行 サービスに寄り添う継続的な改善 おわりに 導入の背景 技術負債の脱却による運用負荷の低減 こちらはWEARで利用するクラウドやAWS内のシステムを中心に利用状況をまとめた概要図です。 Fastlyによるオンプレクラウド間のパスルーティング、Akamaiによる画像リサイズなど、様々なクラウドサービスを活用しています。AWS内ではOpsWorksで構成管理するDigdagというワークフロー基盤やECSクラスタで稼働するRailsのAPIを運用しています。その他にもプッシュ通知を扱うインスタンス群が別のAWSアカウントに存在します。プッシュ通知に関する構成は後述します。 これらのシステムは構築されてから数年が経過しています。次第にシステムの複雑性が増していき、ノウハウを引き継げていない状態でした。そのため、障害や日々の運用に関して、なかなか対応のスピードを出せないという課題がありました。そこで、全社的にも広く利用されており、チーム内でも経験者が多いKubernetesに全てのアプリケーションをリプレイスしようと考えました。 GitOpsによる開発者体験の向上 アプリケーションをデプロイするために、OpsWorksスタックの仕組み 1 や、AWSのCodeBuild、CodePipelineと様々なサービスを利用しています。 障害などのイレギュラーな事象が発生した際に、このようなバリエーションの多さは素早い判断のボトルネックになってしまう傾向 がありました。 普段の開発では特に課題を感じることはありませんでしたが、障害時は上の図ような複数のサービスの構成を思い出しながら調査を進めることになります。 例えば、CI/CDで利用する環境変数だけに焦点を当てても、以下を利用しています。普段は頻繁に設定を変更しないため、どこで何を設定しているのかが分かりにくい状態にありました。 CircleCIのSecret 2 AWS Systems Managerの一機能であるParameter Store 3 AWS Secrets Manager また、Railsアプリケーションをロールバックする際に、時間がかかってしまう課題がありました。 Dockerイメージをlatestタグのみで管理しているので、ECSのタスクを1つ前にビルドしたイメージでデプロイし直すことができないからです。 アプリケーションはRailsのモノレポであるため、ロジックが膨大でありテスト項目も多いです。もう1度ビルドする場合はRevertしてPull Requestを再作成し、CIによるテストを待たなければいけません。さらに、Pull Requestを適用するとDockerイメージのビルドやECSのタスクを更新するパイプラインを再度実行することになります。 そこで、従来のさまざまな仕組みを利用した複雑なデプロイパイプラインを、可視性が高くシンプルなものへ変更したいと考えGitOps 4 を採用することにしました。GitOpsはGitを「Single Source of Truth」 5 として、普段行うデプロイから緊急時のロールバックまで一通りの操作を開発者が普段から利用するGitに集約できます。 GitOpsを実現するツールにはArgo CD 6 やFlux 7 、Jenkins X 8 などがあります。WEARでは充実したGUIに魅力を感じArgo CDを選びました。 このGUIは、機能追加を行う開発チームも利用することを想定しています。 Kubernetesの導入にあたり調査や検証を行なってきたSREとは違い、ECSやOpsWorksを中心に利用してきた開発チームにとってはEKSにも慣れていく必要があります。 全社的にKuberntesの利用は活発ですが、WEARでの採用は初めてであるため、こういった分かりやすさも重要な点と考えました。 リプレイス後の構成 上述の背景を踏まえ、EKSを中心とするシステム構成へと変更しました。以下はその概要図です。 上で紹介したオンプレミスやFastly, Akamaiは今回のリプレイスの対象外となるので省略しています。 以下に重要な変更点を列挙します。 ワークフロー基盤を扱うOpsWorksを廃止してEKSへリプレイス Railsアプリケーションを扱うECSの各クラスタをnamespace単位でEKSへリプレイス 別アカウントに存在するPush通知を扱うシステムをRailsに置き換えEKSへリプレイス(詳細は後述します) AWS CodeBuildやAWS CodePipelineを廃止し、すべてのシステムを後述するGitOpsによるデプロイ方法へ統一 また、リプレイスにあたり以下のAWSサービスを廃止します。 AWS CodeBuild AWS CodePipeline AWS OpsWorks AWS Systems Manager Parameter Store Amazon ECS 別アカウントに存在するAmazon EC2インスタンス 別アカウントに存在するAmazon SQS 別アカウントに存在するAmazon SNS AWSのサービスだけでも廃止できるものが多く、シンプルになったことが分かります。 EKS内で利用する様々なツール リプレイスにあたり、他のAWSサービスと連携したりモニタリングを行うための様々なツールを導入しました。以下はその一覧です。 ツール 用途 argoproj/argo-cd デプロイ, GUI external-secrets/external-secrets AWS Secrets Manager等と連携しsecretを作成 kubernetes-sigs/aws-load-balancer-controller Ingressと連携しAmazon Elastic Load Balancingを作成 kubernetes-sigs/external-dns Ingressと連携しAmazon Route 53 Recordを作成 kubernetes-sigs/prometheus-adapter モニタリング, HPA prometheus-operator/prometeheus-opereator モニタリング, HPA prometheus-community/postgres-exporter モニタリング, HPA DataDog/datadog-agent モニタリング aws/aws-for-fluent-bit ロギング これらのツールはHelm Chartsで管理しています。Argo CDでは通常のマニフェストの他に、Helm Chartsのリポジトリを指定できるので、Argo CD Applicationを宣言してそれぞれのChartを導入しています。以下はArgo CDをApplicationで管理する例です。 apiVersion : argoproj.io/v1alpha1 kind : Application # Argo CDのカスタムリソースであるApplicationを宣言 metadata : name : argocd spec : project : default source : repoURL : 'https://argoproj.github.io/argo-helm' targetRevision : 4.5.0 # Helm Chartのバージョンを指定 chart : argo-cd helm : values : | # values.ymlと同じように設定を記述 server : service : type : NodePort parameters : # パラメータでもvalues.ymlと同じような設定が可能 - name : "server.ingress.hosts[0]" value : "xxxxxxx" destination : server : 'https://kubernetes.default.svc' Helm Chartsやマニフェスト一式はApp Of Apps Pattern 9 で管理しており、以下のようになっています。 複数のEKSクラスタを用意する理由 Kubernetesのクラスタを構築するにあたり、大まかに以下のパターンが一般的です。 1つのクラスタで複数のシステムを扱うマルチテナント(シングルクラスタ) 複数のシステムをクラスタ単位で扱うシングルテナント(マルチクラスタ) WEARでは基本的にはマルチテナントにする方針です。理由は以下です。 モノレポのRailsアプリケーションで複数のシステムを扱い、内部通信が行われるため 少人数のSREチームでKubernetesクラスタを運用しており、運用負荷や改善の横展開を容易にするため しかし、EKSを導入して間もないWEARでは、極端にマルチテナントに振り切らず、 ユーザーへの影響度 という観点で分離しました。 ワークフロー基盤を中心とするクラスタ Railsアプリケーションを中心とするクラスタ ワークフロー基盤を中心とするクラスタは、一部のシステムが停止した場合に直ちに影響があるわけではありません。一方で、Railsアプリケーションを中心とするクラスタは、停止してしまうとコーデを閲覧できなどサービスを提供する上でユーザーに影響を及ぼします。 さらに、Railsアプリケーションを中心とするクラスタは、それぞれのシステムが内部通信する場合を想定できます。同じクラスタ内で管理することは余計な認証処理を実装する必要がなくなる実装上のメリットもあります。 このように、サービスレベルに応じてEKSクラスタを分離することで、 バージョン更新などの運用による影響範囲を縮小できます。 さらに、システムが拡大してもEKSクラスタの数が増えないので、運用負荷が上がることはなく改善のスピードも落ちません。 デプロイ 続いてKubernetesの導入に伴うデプロイパイプラインのリプレイスについて紹介します。 WEARではRailsアプリケーションをモノレポで管理しています。 新たな環境では、開発者がRailsアプリケーションのリポジトリへPull Requestを送り、変更が適用されるとGitHub Actionsのワークフローが起動します。 ワークフローは2つのステップになっています。まずArgo CDがSyncするリポジトリのDockerイメージタグを書き換えます。次にSync先のリポジトリが変更されるとArgo CDが変更内容を検知しEKSへデプロイを行います。 GitHub Actionsにおける最初のステップではDockerイメージをビルドしECRへプッシュします。このとき、GitのコミットハッシュをDockerイメージのタグに登録します。こうすることでコミットごとにユニークなタグを管理できます。 次のステップはGitOps用のリポジトリを取り込み、新たにビルドしたイメージのタグに書き換えます。 Kustomizeでは kustomization.yaml というファイルの newTag でDeploymentに適用するイメージタグを保持しています。 kustomizeの設定は以下です。 apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization resources : - ../../base patchesStrategicMerge : - deployment.yaml images : - name : rails-app newName : rails-app newTag : a3bc8c6fa529fcc64ae6f51dd6133521a28c7468 GitHub Actionsで newTag を変更する処理は以下です。 $ cd ./path/to/overlays/ $ kustomize edit set image ${IMAGE_URI} = ${IMAGE_URI} : ${GITHUB_SHA} このようにすると新しいイメージタグが kustomization.yaml に適用されるので、リモートリポジトリをGitのコマンドを用いて変更します。 また、デプロイ時に行うGitOps用のリポジトリへの変更は、ロールバックにも応用できます 10 。デプロイ前に格納したイメージタグを取得し、 newTag を変更するだけです。 工夫した点 Digdag WorkerにおけるConfigMapの動的管理 RailsのRakeタスクなどを動かすワークフローは、Digdagの機能であるCommand Excecuter 11 を用いて実現しています。 Digdag WorkerのPodに格納されているKubernetesマニフェストを kube-job というツールを用いて実行します。この辺の細かい設計は @calorie が考えてくれました。 詳細は Kubernetes上でDigdagとEmbulkを動かすワークフロー基盤 に記載してあります。 ここでは、しばらく運用してから発生した課題に触れたいと思います。 Digdag Worker内で保持するマニフェストはConfigMapで生成しており、Pod起動時にマウントすることで参照が可能になります。 ConfigMapでファイルを生成するには data を用います。 apiVersion : v1 kind : ConfigMap metadata : name : sample-batch data : task.yaml : |+ apiVersion : batch/v1 kind : Job (省略) 当初は課題に感じなかったのですが、yamlのシンタックスの不備やマニフェストとして正しいかを検知できず、誤った内容でリリースしてしまう可能性がありました。 開発者もKubernetes secretや起動コマンドの調整などの変更を加える機会が多く、なるべく管理しやすい方法を模索しました。 この問題を解決するために、KustomizeのconfigMapGenerator 12 を採用しました。 configMapGeneratorは名前とファイルを指定することで、動的にConfigMapを生成してくれます。 続いてはその方法です。 /config 内にファイルを配置しています。 この task.yaml は上述のConfigMapにおける data 内で記載したマニフェストがそのまま格納されています。 └── base ├── config │ └── task.yaml # `configMapGenerator`で指定するファイル ├── deployment.yaml └── kustomization.yaml 次に configMapGenerator にファイルパスを指定します。 また、Argo CDのSyncを前提としているためConfigMapを再作成するように generatorOptions を設定しています 13 。 apiVersion : kustomize.config.k8s.io/v1beta1 kind : Kustomization resources : - deployment.yaml configMapGenerator : - name : batch-sample files : - ./config/task.yaml generatorOptions : annotations : argocd.argoproj.io/compare-options : IgnoreExtraneous # Argo CDでSync時に更新を行うために必要 Deploymentには configMapGenerator で指定した名前を記述します。 apiVersion : apps/v1 kind : Deployment metadata : name : digdag-worker spec : template : spec : volumes : - name : batch-sample-volume configMap : name : batch-sample # configMapGenerator.nameと同様 containers : - name : digdag-worker volumeMounts : - name : batch-sample-volume mountPath : /sample このように設定した後に、 kustomize build を実行すると動的にConfigMapが生成されます。 apiVersion : apps/v1 kind : Deployment # (省略) volumes : - configMap : name : batch-sample-ccfh9mc2t6 # ハッシュ付きでConfigMapを読み込んでいる name : batch-sample-volume batch-sample-ccfh9mc2t6 というConfigMapが動的に生成され、Deploymentにも反映されています。 Push通知におけるリアルタイム性を意識した構成 Railsアプリケーションを中心とするEKSクラスタの最初の移行対象としてプッシュ通知を扱うシステムを選定しました。 冒頭の構成図では省略しましたが、以下のようになっています。 プッシュ通知を扱うシステムは非常に古く、当時運用が活発だった頃のドキュメントが少ないため、運用負荷が高い状態でした。 また、サービスの成長に伴い配信する通知の量が増加し、細かい部分に改善を加える必要がありました。そのためSidekiq 14 へリプレイスすることになりました。 ここではSREとバックエンド両チームで工夫してきたことをご紹介します。 WEARで扱う通知は大まかにリアルタイム性が重要かそうでないものに分類できます。 それらを分類してピッチコントロールできるように、以下のような構成にしました。 今後は新規の機能追加に通知が伴う場合、キューを分けるべきかを案件の特性に合わせて決めることができます。現状の負荷と照らし合わせて、HPA等の細かいチューニングを行うことができるはずです。 また、頻繁にスケールすることが想定されているのでヘルスチェックやGraceful Shutdownにも対応しました 15 。 振り返り 運用負荷が下がり積極的な改善に集中できる体質に OpsWorksの運用負荷は高い状態でした。具体的な運用として以下のようなものがありました。 メンバー異動時のOpsWorks Stacks Usersの管理 16 OpsWorksで管理するEC2インスタンスの台数やオートスケールの見直し インスタンス内で動作するミドルウェアの運用(Chefのcookbookの調整) EC2インスタンス内で実行するDigdagジョブのインフラ起因のトラブルシューティング(OOMなど) 切り替えは2022年2月に行いました。下のグラフが示す通り、2021年8月と約8倍ジョブが実行されていることがわかります。 またDigdag Workerとジョブのインフラが切り離されたことで、実行回数が増えても失敗率を減少傾向に止めることができました。 もちろん今後も個別のジョブがメモリ不足により落ちるケースは想定できます。そういった場合はKubernetesマニフェストを修正して対応すれば良く、これまでのようにインスタンス自体のキャパシティに引きずられることはありません。 さらに、ジョブ全体の停止につながる障害も2022年2月以降は発生しなくなりました。 リプレイス前は、月に1件以上インスタンスがOpsWorksのLayerに紐づかなくなる障害が発生していました。 このようにOpsWorksのLayerにプロセスが存在しない場合を検知しています。構成管理の設定が原因で起動できず、調査や復旧に時間を割いていました。 運用負荷が下がったことで、本記事で紹介してきたような改善に集中できました。 継続的に改善に取り組むことが大事 Kubernetesを導入した目的はその拡張性を活かして現行のシステム課題を大きな枠組みで捉え直し、サービスを運営する上で発生する様々な問題を継続的に改善することにあります。プロジェクト発足時の計画に捉われすぎず、解決策や課題を共有し合う取り組みが重要です。 SREチームでは、本記事で取り上げたKubernetesへのリプレイスプロジェクト以外にもさまざまな活動を行なっています。 リプレイスを行う主担当だけでなく、チーム全員がKubernetesによる改善を行えるように「運用改善」という取り組みを始めました。 これは、キャッチアップや日々の学習で得た知識や課題をアウトプットする場です。週に1度、Kubernetesの新しい技術をモブプロで検証してみたり、日々の運用で感じた課題に取り組み結果を共有しあうというものです。 上に挙げたconfigMapGeneratorもこの取り組みの中で実施しました。既に半年ぐらいこの活動をやっていますが、今ではKubernetesに限らずさまざまなことが議論されています。参考までに、運用改善で行った取り組みをいくつか挙げます。 ローリングアップデートによるKubernetesバージョンの更新方法の検証 kube-tools を利用したkubevalによるマニフェストチェックをCIに導入 aws-loadbalancer-controllerとCognitoを組み合わせてDigdagやArgo CDのGUIに認証を導入 Podのログ基盤をS3、Athenaへ統一 Prometheusと postgres_exporter を導入しDigdagのキューサイズに応じてスケールするHPAを導入 GitHub ActionsとAWS間の認証をOIDCへ統一 GitHub Runnerを活用し digdag push などの内部通信を KubeDNS へ置き換える ECRにPrivate Linkを導入してNAT Gatewayのコストを削減 今後の展望 アプリケーションの段階的な移行 引き続きECSクラスタを移行していきます。 残りのECSクラスタで稼働するシステムは既に移行を終えたRailsアプリケーションと同じであり、起動方法を変更すれば良いものがほとんどです。 しかし、移行作業の難易度は低くてもWEARの中心を担うAPIであるため、非機能面での課題や切り替え作業に関して考慮すべき点は増えるはずです。 半期ごとに対象を定め、重要度が低いシステムから順次移行します。 サービスに寄り添う継続的な改善 移行と並行しながら改善を進めることができたのはチーム全員が改善を楽しみながらアイデアを出し合い積極的に取り組んでくれたからです。 今後もKubernetesに限らずですが、運用改善やモブプロをはじめとしたチームでの取り組みを継続する予定です。 おわりに クラスタの構築方針やGitOpsの採用など土台となる部分を整備しつつ、設計の見直しが必要であるワークフロー基盤やPush通知の移行を終えることができました。 Kubernetesやその周辺の技術は変化が激しいので、当初は最善と思えてもあっという間に新しい方法が出てきます。そういった変化を楽しみつつ、より良いサービスにしていきます! WEARでは一緒にサービスを改善してくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! https://hrmos.co/pages/zozo/jobs/0000021 hrmos.co AWS OpsWorks | Deploying Apps ↩ 詳細は circleci|環境変数の使用 を参照してください。 ↩ AWS Systems Manager Parameter Store ↩ GitOps | What is GitOps? ↩ wearveworks | Guide To GitOps ↩ 詳細は Argo CD を参照してください。 ↩ 現在ではv1はmaintenance modeとなっており、v2である flux2 を参照してください。 ↩ 詳細は jenkins-x を参照してください。 ↩ App Of Apps Pattern ↩ Argo CDにはGUIやCLI上で1つ前のrevisionに戻す機能があるものの、緊急時の場合でも普段から行なっている手法で対処できる方が良いと判断しました。 ↩ Digdag|Command Executor ↩ Declarative Management of Kubernetes Objects Using Kustomize|Generating Resources ↩ Configmapが差分として検知されることでSyncに失敗する事象が発生しました。Argo CDのSync時の挙動を制御するためのアノテーションを設定しました。詳細は Ignoring Resources That Are Extraneous に記載されています。 ↩ Sidekiq ↩ sidekiqはバージョン6以前とそれ以降でGraceful Shutdownの手法が異なっており、詳細を こちら にまとめました。 ↩ Managing AWS OpsWorks Stacks Users ↩
アバター
こんにちは!ZOZOTOWN開発本部 Android1ブロックでAndroidテックリードを務めている いわたん です。 2022年6月30日のお昼時に、おいしい健康さん、Rettyさん、アンドパッドさんと4社合同でAndroidエンジニア向けオンラインイベント、「 Jetpack Compose 導入事例【おいしい健康|Retty|ZOZO|アンドパッド】 」を開催しました。 私は「Jetpack Composeでの画面遷移」というタイトルで発表しました。 イベント開催と登壇のきっかけ おいしい健康さんの sobaya15 さんからTwitterのDMで弊社の 堀江 に勉強会の話をいただき快諾したそうです。当初からJetpack Composeをテーマにしたいとのことだったので社内で調整が始まり、直近でComposeを使った開発に関わっていた自分に白羽の矢が刺さりました。 自分に登壇しないかとお誘いがありましたが、どうしても二人の子供の世話の兼ね合いで夜開催だと参加が難しいため、「お昼開催だったらOKですよー」と返しました。そうしたところ、本当にお昼開催の枠を取ってきてくれました。感謝です。 発表資料の準備 ネタだしの段階では、新規リリースしたARメイク関連の話やJetpack Composeのテスト関連、Jetpack Composeの画面遷移とどれを話すか悩みました。発表時間内に収まりそうな題材、ちょうどそのタイミングで調査をしていたということでJetpack Composeの画面遷移について話すことにしました。他の題材に関してはまた別の機会に登壇しようと思います。 久しぶりの登壇ということもあり、張り切って資料を作成しました。しかし、開催2日前にチーム向けに登壇練習をしてみたところ、持ち時間10分に対して、結果17分と時間を盛大にオーバーをしていました。色々と話したいことがたくさんありましたが、必要最低限まで情報を削っていくのは辛い作業でした。 謝辞 アンドパッドさんには、準備や段取りをスムーズに行っていただいて感謝です。おいしい健康の霜重さん、Rettryの松田さん、アンドパッドの橋詰さん、参考になるお話をたくさんして頂き楽しかったです。一緒に登壇して頂きありがとうございました。参加者の皆さんには、YouTubeやTwitterで声援ありがとうございました。何かひとつでも知見を持って帰っていただいて生かしていただけたら幸いです。 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催や登壇など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
はじめに こんにちは、ブランドソリューション開発本部 WEAR部 Androidブロックの武永です。普段はファッションコーディネートWEARのAndroidアプリを開発しています。 みなさん、GitHub Actionsでの自動化進めてますか? 毎回ローカルでパッケージをビルドしストアに上げその際に人為的ミスが起こったり、担当者の作業が止まってしまっていませんか?GitHub Actionsを使えば、Google Play Console上に自動アップロードを行うことができます。 導入方法 使用するライブラリは r0adkll/upload-google-play です。 github.com 選定理由は次の通りです。 GitHub Actionsで利用できる 段階的公開や、クローズドテストにも対応している それでは実際に見ていきましょう。 .github/workflows直下にファイルを追加 実行ファイルを.github/workflows直下に作成します。.githubとworkflowsのディレクトリがない場合は作成します。 リポジトリから参照するsecretの追加 次にリポジトリから参照するシークレットを追加します。 GITHUB_TOKEN GitHubの設定から追加します 既に作成済みの場合は同じものを流用できます ENCODED_RELEASE_KEYSTORE キーストア Base64形式のテキストにエンコードします KEYSTORE_PASSWORD キーストアのパスワード KEY_PASSWORD キーのパスワード SERVICE_ACCOUNT_JSON サービスアカウントを参照します 次の項目で説明します 上記のシークレットは必須項目です。 Google Cloud Platformでサービスアカウントを作成する Google Cloud Platformに移動し、プロジェクト内でサービスアカウントの作成画面に移動します。サービスアカウント名、サービスアカウントID、サービスアカウントの説明(任意)を入力します。ロールは参照者以上にします。オーナー権限でも可能ですが過剰な権限になってしまうので特に必要がなければ参照者を推奨します。サービスアカウントユーザーロールに、作成したアカウントを入力します。 サービスアカウントの秘密鍵を作成する サービスアカウントを作成したら次は秘密鍵です。サービスアカウントの詳細からキーを選択し、作成します。キーのタイプはJSONにします。 キーは再度ダウンロードできないので大事な場所に保管しておくことを推奨します。 作成したユーザーを招待する サービスアカウントの作成が完了したらGCPからGoogle Play Consoleへ作成したサービスアカウントを招待します。サービスアカウントはZOZOの集団にいるので今回はWEARのプロジェクトに招待する必要があるため権限を付与しています。ユーザーを招待する際にどの権限を付与するかチェックを入れます。 Google Play Console上で権限を付与する アカウントの招待後に同じ権限でアプリへの権限を付与します。 実際のコード トリガー設定 name : Deploy Google Play Console on : pull_request : branches : - main types : [ closed ] まず、どのブランチがトリガーになるかを設定します。プルリクエストがクローズされると発火します。マージ先はmainです。ブランチをPushしたときにでも設定は可能です。 ビルド jobs : build : runs-on : ubuntu-latest steps : - name : Checkout uses : actions/checkout@v2 - name : set up JDK 11 uses : actions/setup-java@v1 with : java-version : 11 Java 11でビルドします。 Keystoreをデコード Keystoreは単純なバイナリなのでBase64形式でencodeし、文字列としてシークレットに保存する必要があります。 - name : Decode Keystore run : echo ${{ secrets.ENCODED_RELEASE_KEYSTORE }} | base64 --decode > ${{ test.keystore }} シークレットから読み取る際にdecodeすることによって元のファイルに戻せます。 App Bundle作成 - name : Generate AAB run : ./gradlew :app:bundleRelease env : RELEASE_KEYSTORE_STORE_PASSWORD : ${{ secrets.KEY_STORE_PASSWORD }} RELEASE_KEYSTORE_KEY_PASSWORD : ${{ secrets.KEY_PASSWORD }} App Bundleを作成します。apkも作成できますがApp Bundle化するに越したことないので対応しましょう。 サービスアカウント作成 - name : Create service_account.json id : createServiceAccount run : echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json サービスアカウントを参照します。 デプロイ - name : Deploy to Play Store id : deploy uses : r0adkll/upload-google-play@v1.0.15 with : serviceAccountJson : service_account.json packageName : com.test.deplaoy.app releaseFile : app/build/outputs/bundle/googlePlayRelease/app-googlePlay-release.aab track : beta whatsNewDirectory : whatsnew/ Google Play Consoleにデプロイします。こちらで今回採用したライブラリを使用しています。今回はbeta版にアップロードをし、releaseFileについては作成されたApp bundleが置かれる場所を指定しています。 Slack通知設定 - uses : 8398a7/action-slack@v2.5.2 if : failure() with : status : ${{ job.status }} text : リリースビルドが失敗しました。確認をお願いします。 mention : 'here' env : GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }} SLACK_WEBHOOK_URL : ${{ secrets.SLACK_WEBHOOK }} - uses : 8398a7/action-slack@v2.5.2 if : success() with : status : ${{ job.status }} text : リリースビルドが完了しました! env : GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }} SLACK_WEBHOOK_URL : ${{ secrets.SLACK_WEBHOOK }} 任意設定ですが、Slack通知を設定します。 8398a7/action-slack@v2.5.2 はGitHub ActionsからSlack通知するライブラリです。 アップロード後の運用 クローズドテストが優秀! WEARの開発では上記のフローでリリースをしています。直接本番リリースも可能ですが、本番と同じ形式のクローズドテストで動作確認が行えるので確認後に本番リリースをします。クローズドテストは招待したメールアドレスのユーザーが確認可能です。 リリース前レポートで事前にエラーを発見できる リリース前レポートも優秀で、致命的なクラッシュを事前に知ることができます。Google Play Console上でも便利な機能が多いので、ありがたいですね。 今後の課題 この自動アップロード導入により作業効率が上がりました。CIツールは多様な種類がありますが、個人的にGUIがしっかりしているものが使用しやすかったです。導入後にさまざまな課題がでてきました。例えば、他言語対応しているリリースノートをスプレッドシートで定義しておりそれをGoogle Play Cosole上で手動入力しているのですが、こちらも自動化させたいです。 また、今回は採用しなかったのですがGradleの知識があれば Gradle Play Publisher を使用できると良いと思いました。 最後までご覧いただきありがとうございました。ZOZOでは、各種エンジニアを採用中です。ご興味のある方は以下のリンクからご応募ください。 corp.zozo.com
アバター
こんにちは。ECプラットフォームサービスSREチームリーダーの川崎( @yokawasa )です。本記事では、コードレビューを通じたチームのパフォーマンス向上のための取り組みについてご紹介します。なお、コードレビューそのもののテクニックに関する話はしないので、あらかじめご了承ください。 目次 目次 はじめに コードレビューはチーム全体のパフォーマンス向上のため 複数ユニット、複数チームで行う 活動状況を定量的に評価する コードレビュー体験を向上させる レビュアーの負担を減らす 同期・非同期コミュニケーションを使い分ける 参加しやすい雰囲気を作る 1. 心理的な安全性を高める 2. チームの共通目標にする さいごに はじめに まずはじめに、我々はGitHubのPull Request(以下、PR)機能を活用してコードレビューをしています。下記の記事でも書いているようにIaCとCI/CDを基本ルールとしています。可能な限りすべての構成はコードで管理し、PRレビューでApprovalされた更新内容をメインブランチにマージし、CI/CDを通じて環境にデプロイします。よって、本記事におけるコードレビューはPRにおけるレビューと同義になります。 techblog.zozo.com コードレビューはチーム全体のパフォーマンス向上のため コードレビューは一般的に「ソースコードの品質向上」の観点で語られることが多いかと思います。コードの保守性・効率性・信頼性の確認、規約・ルールの徹底、テスト内容や関連ドキュメントの確認などさまざまな目的があります。しかし、我々がコードレビューをする理由はこれだけではありません。我々はコードレビューを通じて究極的にはチーム全体のパフォーマンス向上を目指しています。我々が考えるパフォーマンスの高いチームとは、高いレベルで知識の共有がされ、十分にコラボレーションが促進されているチームを指します。順を追って説明します。 コードレビューは、レビュアーとレビュイー間で知識や技術のトランスファーを行い、互いに学びあえる最高のスキルアップの場です。レビュアーはレビュイーや他のレビュー参加者により良い解決策やアイデアの提示、ベストプラクティスなどの共有ができます。また、他人が書いた洗練されたコードをみることで技術力向上にもつながります。 また、お互いのシステムや活動を深いレベルで理解できるようになると、より具体的なコミュニケーションが取りやすくなりチーム内コラボレーションの促進が期待できます。さらに、これを複数チームに広げることでその組織全体のコラボレーション促進が期待できます。結果、チームや組織全体のパフォーマンスの向上が期待できるというわけです。 複数ユニット、複数チームで行う 我々は複数ユニット、複数チームでクロスコードレビューをしています。 主担当外のユニットやチームが関わることはスピードや効率性の低下、ノイズの増加につながるといったマイナス面はあるかと思います。しかしながら、我々はZOZOTOWNの成長に伴い担当者が増えたとしても組織がサイロ化することなくスケールできることを目指しており、そのためには必要なトレードオフだと考えています。 クロスコードレビュー推進の発端は、2021年当時私が所属していたZOZOTOWNマイクロサービスを担当するSREチーム(以下、PF-SREチーム)の拡大に伴い生じた課題感からでした。 PF-SREはZOZOTOWNリプレイスを推進するSREチームとして2020年4月に発足しましたが、リプレイスの進行とともにチームは拡大し担当するシステムやサービスが増えていきました。そうなってくると、全員がモノリシックにすべてを見るわけにもいかなくなってきたので、チームで担当するシステムを適度な粒度のドメインに分けてドメインごとにユニットを組んで動くようしました。 そこで生じてきた問題がサイロ化です。担当ユニットの専門分野については詳しいが、隣のユニットのことはよく分からない状況が見られるようになってきました。各システムの更新頻度を考えると全メンバーが勉強会・定例会などで同期するには難しい状況がありました。コードレビューにおいては、一部のユニット担当者でのみ議論がなされるようになり、他のユニットで生まれたベストプラクティスや課題感がうまく全体に共有されづらくなりました。また、レビュアーがユニットごとで属人化するようにもなりました。 そうした課題の解決策の1つとして行ったのが、複数ユニットによるクロスコードレビューです。定例会や勉強会と比べ非同期でできるため時間的制約が少なく、また高頻度で回せることが魅力でした。そして、実施した結果、サイロ化は緩和され、知識共有とコラボレーション濃度は高まり、チーム力の向上がみられました。 なお、PF-SREチームはチーム内複数ユニット体制から今では下図のようなドメインを責務とした複数チームに分割されていますが、引き続き複数チーム間でクロスコードレビューをしています。 さらに、これは実験的でありますが、2022年4月よりコードレビュー参加対象をマイクロサービス担当チームに限らずZOZOTOWNを担当するSRE部配下すべてのチームに広げて実施しています。この試みについてはまだ目立った効果は見られないものの、他チームのシステムに対する関心度が変わったであるとか、他チームのレビュー参加意欲が上がったなどポジティブなフィードバックが得られはじめています。 活動状況を定量的に評価する コードレビューの活動状況は定量的な数値データを元に把握し、継続的な改善に努めています。その取組について簡単に紹介します。 まず、コードレビューの活動状況を定量的に把握するために Findy Teams を活用しています。Findy Teamsは、GitHubにおけるPR作成数、レビュー数などさまざまな活動をメトリクスとして可視化してくれるサービスです。活用することで、個人やチームレベルの活動状況を定量的に把握できます。 findy-teams.com 下記はチームの活動状況が確認できるFindy Teamsの「チームレポート」ページのスクリーンショットになります。 中でも、とくに参考にしているのはコードレビューの活動量を示す以下のメトリクスになります。 レビュー数: PRに対してレビューした数(PRコメント除く)です 自分がレビューしたプルリク数: レビューに関わったPRの数です プルリクに対する自分のコメント数: PRへのコメント総数です。コメント数が多いほど多くの議論が行われている可能性があります。一方、コメントする必要のない良質なPRもあるので多いから良いという判断は一概にはできません 自分が最初のレビューをするまでの時間: 最初にレビューするまでの時間です。数値が短いほどよいと言えます この結果データは定期的にレビューしています。Findy Teamsを使い始めて最初の半年くらいは、”マネージャーごと”として一人もくもくとチームの活動状況を眺めていました。結果が思わしくないようだったらどうすればコードレビューが促進されるのかチームミーティングで議論を促していました。しかしそれではスケールしていかないだろうと考えを改め今年から”チームごと”としてメンバー全員で結果データを見ていくようにしました。現在は、週次のチームミーティングのアジェンダの1つとして「チーム生産性活動レビュー」を設け、チーム全員で結果データをみて振り返りを実施しています。 コードレビュー体験を向上させる ここではコードレビューの体験を向上させるために実施したことや、結果的に実施したことでコードレビュー体験が向上したことについてご紹介します。 レビュアーの負担を減らす レビュアーの負担を減らしレビュアーフレンドリーなPRにすることで、レビュアーのストレスは減り、コードレビューの速度は加速されます。以下、レビュアーの負担を減らすために実践していることを簡単に紹介します。 PRの粒度は小さくして目的を絞ります。巨大なPRは見るのが億劫になります。また、1つのPRに複数の目的が混在している場合も同様です。PR粒度を小さくする理由については Google - eng-practices - Small CLs が参考になります PR説明は簡潔で分かりやすく書きます。 PRテンプレート を活用し、PRの目的、関連Issue/PR、テスト内容、変更による影響範囲などPRへの記入項目をあらかじめ用意します 作成されるPRにはGitHub Actionsで ラベル を自動付与します。ラベルはレビュアーが同様の種類の課題をすばやく整理して集約するのに役に立ちます。ラベルの自動付与はGitHub Actions経由で、 labeler を活用しています PRで提案されるコードは、可能な範囲でGitHub Actionsにより事前に自動チェックし、レビュアーの負担を軽減します。たとえば、フォーマットや規約遵守、デプロイ先環境との差分、利用APIやパッケージのセキュリティ課題などがあります 同期・非同期コミュニケーションを使い分ける 非同期・同期コミュニケーションをうまく使い分けることで、コードレビューを効果的にすすめることができます。 コードレビューは非同期コミュニケーション中心の活動になりますが、非同期で効率的に時間を有効活用できる一方、フィードバックが得られるまでのリードタイムが長くなるという課題があります。また、複雑な内容になってくると文章だけではツラいです。 このような課題に対して弊チームでは、非同期コミュニケーションに固執することなく、同期コミュニケーションをうまく取り入れるようにしています。素早い返信を要したり、濃度の高い会話が必要と感じたらビデオ会議で直接会話したり、一緒にモブプロを行うなど同期コミュニケーションにスイッチします。必要に応じて同期・非同期を行き来します。 また、レビュアーと一緒にモブプロしてからPRを作成するという流れもあります。この場合のレビューは間違いなくリードタイムが短くなります。 参加しやすい雰囲気を作る 先述のとおり、コードレビューを通じて知識のトランスファーやコラボレーション促進を目指しています。したがって、コードレビューにはできるだけ多くのメンバーが参加することを期待しています。以下、コードレビューに参加しやすい雰囲気を作るために工夫したこと2点紹介します。 1. 心理的な安全性を高める 担当外の案件のコードレビューだと関連システムを熟知しておらず的確なレビューができず億劫になりがちです。ただし、コードレビューは学びの場であり、コミュニケーションの場でもあります。システムを熟知していないメンバーも議論に参加できるように、PR上ではレビュアーが仕様や不明な点をレビュイーに気兼ねなく質問できる場であるという、雰囲気づくりを心がけました。 2. チームの共通目標にする 私がリードを務めるチームではチーム目標の1つに"クロスレビューへの貢献"をかかげています。全員が同じ方向を向いてコードレビューを盛り上げていくことを共通目標とすることで、レビューに参加し貢献することのモチベーションはあがり、協力しやすい雰囲気が生まれやすくなります。先述のとおり週次ミーティングでコードレビューの活動結果をレビューして、継続的な改善に努めています。全員が同じ目標をもっているので改善のためにの活動も足並みを揃えて実施できます。 さいごに コードレビューにはさまざまな目的があるかと思いますが、我々はコードレビューを通じてチーム全体のパフォーマンス向上を目指しています。本記事ではそのための取り組みについてご紹介しました。我々の取り組みが少しでも参考になれば幸いです。 なお、リモートワーク下においてはコードレビューの実施意義はとくに大きくなったと感じています。コードレビューは時間や空間に囚われることなく行える有用な非同期コミュニケーションツールの1つです。ZOZOではコロナ禍を機に多くの社員がリモートワークを行うようになりましたが、柔軟に時間を調整して実施できるコードレビューのリモートワークとの相性の良さを強く実感しています。 さいごに、ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 https://hrmos.co/pages/zozo/jobs/0000010 hrmos.co また、カジュアル面談も随時実施中です。まずは話を聞いてみたいという方は、以下のリンクからご応募ください。 hrmos.co
アバター
こんにちは、カート決済部の佐藤です。普段はZOZOTOWNカート決済サービスの新機能開発、既存改修、運用保守を担当しております。 弊社はモノリスからマイクロサービスへのリプレイスを進めており、カート決済サービスも先日リプレイスPhase1の記事を掲載いたしました。 techblog.zozo.com 本記事ではカートリプレイスPhase1全体を振り返りつつ、リプレイスプロジェクトを進める中で苦労した点や得られた知見等の、リプレイスの裏側をご紹介したいと思います。 カート機能はECサイトの中核を担う機能であり、障害のリスクを考えるとドラスティックな改修には中々手を出しにくい機能だと思っています。しかし、弊社ではリプレイスをしたことで確実に前進できたため、同じようなお悩みを抱えている方の参考になれば幸いです。 はじまり カートリプレイス計画の始動 カートリプレイス計画の策定 アーキテクチャ構成 いよいよ負荷試験 プロダクション環境を使った負荷試験 負荷試験で見つかった課題 課題(1)KDSシャードの一部しか使われておらず想定した性能が出ない 概念図 課題(2)Javaアプリケーション起動時のコールドスタート対応(JVM暖気) 課題(3)エラーを返しているがカートに商品が入っている そしてリリースへ 良かった良かった・・・と言いたいところだが エラーの要因 まとめ 最後に はじまり 2021年元旦、特定商品へのカート投入リクエストの集中によりデータベースのキャパシティを超えてしまいエラーが多発する事象が発生しました。そのようにアクセスがスパイクするような商品を弊社では過熱商品と呼んでいます。 以前から過熱商品の販売によるアクセス数の増加はありましたが、2021年元旦は圧倒的に群を抜いたアクセス数を記録し、明らかに一般ユーザーの導線ではなくBOTによる過剰アクセスと判断できました。当時は過剰アクセスへの対抗策が少なく、結果的に数時間カート投入・注文がしづらい状況となり損害は大きいものとなってしまいました。 その後、BOTによる過剰アクセスにも耐えうるシステムの整備が急務となりましたが、以下の状況により問題はより深刻になっていきました。 インフラ、アプリケーション両軸で対応を進めるものの、改良版BOTとのいたちごっこが続く さらに追い打ちとしてコロナにより外出できない時間が増えたことによるトラフィックの増加 カートリプレイス計画の始動 そんな中、小手先の改修に限界を感じはじめていたのと、弊社はリプレイス過渡期であり、カートシステムについてはオンプレミスで動いているため、抜本的な「スケールできるシステム」の必要性が高まっていました。 「リプレイスを推進するためには組織から変える」という弊社VPoE @sonots の舵取りのもと、カートシステムのリプレイスを推進するために新生カート決済チームが誕生しました。 この組織変更が無かったら通常業務もやりつつ片手間でリプレイスとなっていたため、リリースまで大分時間が掛かっていたはず。まず組織変更するというのは大正義! カートリプレイス計画の策定 いざリプレイス計画を策定しようとすると、現状の課題解消のため「あれもこれも」となりがちです。弊社もまさにその状況となり、ロードマップを引くのに苦労しました。しかし、やりたいことが増えるほどリリーススパンも長くなりエンジニアのモチベーション低下にも繋がりやすく、ビッグバンリリースによるリスクも高まります。「リリーススパンは半年+α」とし、その中でできることをマイルストーンに落とし込んでロードマップを引いていきました。 そして、Phase1では主に過熱商品販売時のBOTによる過剰アクセスにも耐えうるシステムを目的に、以下ゴールを設定しました。 キューによるキャパシティコントロール可能なシステム 目標値として過去のBOTアクセス数からサンプリングしたアクセス数×N倍に耐えうる値 欲張りすぎず、豪華すぎる仕様にしない。 課題を正しく理解する前にどうしても解決を急いでしまいがちなため、そこのバランスは特に意識していました。合わせて技術的に正しくてもサービスへの貢献度が低いなら成果として不十分なため、解決すべき課題を明確にしてスコープを小さくすることも意識しました。また、事前にリリーススパンを決めたことで、そこから逆算した形で落としどころを決めることができました。 アーキテクチャ構成 カートリプレイスPhase1のアーキテクチャ構成の概念図は以下となります。 Phase1では、Cartデータベースの前段にキューイングシステムを置くことでキャパシティコントロールを実現しています。キューイングサービス選定の際、AWSが提供しているサービスという点より以下2択となりました。 Amazon Simple Queue Service Amazon Kinesis Data Streams(以下、KDS) カート投入リクエスト数に耐えうる性能、及び可用性の観点からKDSを採用しましたが、詳細は以下の記事にまとまっているためご興味のある方はご覧ください。 techblog.zozo.com いよいよ負荷試験 無事製造も終わり、いよいよ負荷試験です。キュー導入によるチューニング等は発生するだろうとある程度身構えてはいたものの、想定以上のトラブル続きでした。 プロダクション環境を使った負荷試験 今回のアーキテクチャでは、キューイングサービスにKDSを採用しています。もちろん単体での性能検証、負荷試験を実施しておりますが、カート機能はECサイトの中核を担う機能であり、リプレイスによりユーザビリティを損なってしまうのは本末転倒です。可能な限りUXを損なわずに最適なキューの並列度を探るには、やはりプロダクション環境での負荷試験が必須だと考えました。 プロダクション環境を使うにあたりできるだけサービスに影響が出ないような工夫もしており、詳細は以下の記事にまとまっているためご興味のある方はご覧ください。 techblog.zozo.com 負荷試験で見つかった課題 ここでは負荷試験で見つかった課題と、解決に向けたアプローチについていくつかご紹介させていただきます。 課題(1)KDSシャードの一部しか使われておらず想定した性能が出ない シャード数増減によるStreamの付け替えや、デプロイ・スケールアウト等で新規にWorkerプロセスが起動するタイミングのレスポンス遅延が目立っており、時間経過とともに遅延が解消されていくという事象が発生しました。 概念図 原因 Kinesis Client Library(以下、KCL)のCustomMetricsを見ていくと、新規Workerアプリケーション起動時にシャードの一部しか使われていませんでした。さらにそこからブレークダウンしていくと、以下のKCLパラメータが起因していることが分かりました。 KCLパラメータ 概要 デフォルト値 maxLeasesForWorker アプリケーションの単一のインスタンスが受け入れるリースの最大数 INT_MAX maxLeasesToStealAtOneTime アプリケーションが同時にスティールを試みるリースの最大数 1 ①最初に立ち上がったWorkerが「maxLeasesForWorker:INT_MAX」により、INT最大数のシャードを紐づけようとしていた ②その後KCLの負荷分散処理により他Workerよりリースをスティールするが、「maxLeasesToStealAtOneTime:1」によりスティール最大数が1のため均等化にも時間が掛かっていた 解消に向けたアプローチ ①maxLeasesForWorkerの見直しにより、最初に起動したWorkerがINT_MAX分のシャード紐づけを行わなくなり、適切なシャード数のみ紐づけを行う ②maxLeasesToStealAtOneTimeの見直しにより、KCL負荷分散処理にて1度にスティールできる数が増えたことで高速な均等化が可能となった 初期起動時に限定されていたのと時間経過とともに解消されていたためはまりました。が、Stream、WorkerがN:N構成、かつKCLパラメータがデフォルト値だったことにより発生した事象でした。負荷試験で頻繁にStreamを変更したり、Pod再起動等を行っていたため早期発見に繋がりました。 課題(2)Javaアプリケーション起動時のコールドスタート対応(JVM暖気) 上記により初期起動時のシャード数による遅延は解消されましたが、Javaアプリケーション起動時の重さも課題となり、コールドスタート対策が必要となりました。 JVMの暖機運転 JVMはJIT(Just In Time)コンパイラによるコンパイル方式のため、都度プログラム実行時にコンパイルが行われます。そのため、起動直後の初回プログラム実行時が特に重く、予めアプリケーションを実行してコンパイル済みにしておく暖機運転が求められます。 解消に向けたアプローチ 今回作成したWorkerはKubernetes上で稼働しております。Kubernetes上で稼働するアプリケーションの暖機方式としてSidecar方式、postStart方式等がありますが、今回はpostStart方式を採用しています。 ①KubernetesのpostStartを使ってコンテナ起動時に自エンドポイントへ暖機用パラメータでリクエストする ②後続APIでは暖機用パラメータのリクエストであれば後続処理を行わない このようなフローにすることでユーザー影響を最小限にしつつ、コールドスタート問題を解消させています。 課題(3)エラーを返しているがカートに商品が入っている テストの中で負荷を上げていくと、レスポンスでエラーを返しているが、実際にはカートに商品が入っているケースが発生しました。 原因 サービス間でポーリングタイムアウトを検出しているものの、Workerがdequeueして後続のデータベースにリクエストを投げていたことが原因でした。 解消に向けたアプローチ 課題解消のためには、各サービス間のタイムアウト値とKDSシャード並列数の組合せを考慮する必要がありました。タイムアウト値の延長やシャード並列数を下げることで本事象は解消できますが、単純な変更ではカート投入リクエストのレイテンシが高くなりユーザビリティに影響が出てしまいます。 ある程度の仮説を立てた上でテストシナリオを再構築し、しきい値の二分探索を行いながら最適なタイムアウト値やシャード並列数を探りました。そうすることでユーザビリティを担保しつつ、課題を解消できました。 いずれもマイクロサービス単体での負荷試験では検知しづらい課題でした。事前準備や夜間帯というコストを踏まえても、プロダクション環境を使うことで得られた物は大きかったです。エンジニアの理想で言えばプロダクションと同等の検証環境がベストですが、オンプレ環境だとコスト的に難しいのも実情です。 そしてリリースへ 度重なる負荷試験で全ての目標値をクリアし、万が一に備えてフラグによる切り戻し制御も準備した上で、カナリアリリースによるプロダクションリリースを行いました。 成果として、2021年元旦の過熱商品と同等のトラフィックのある商品で、大幅なエラー率削減を達成できました。 また、同時にAPM(Datadog)によるダッシュボード化も実施しており、各種数値が可視化されたことでモニタリング精度向上、及び障害発生時の原因切り分けまでの時間短縮も実現できました。ダッシュボートはサービス単体のみではなく、APIからWorkerまでの一連のフローをリクエスト単位で見れるような工夫もしております。 以前は過熱商品販売の度にリアルタイム監視をしており現場がピリピリしていましたが、監視コストが下がることでサービスは勿論、組織文化にも良い影響を与えることができました。 良かった良かった・・・と言いたいところだが リリース後も問題なく安定稼働しており一安心といったところでしたが、ある日リプレイスPhase1の目標値を大きく超える商品の販売により、再度エラーが多発する事態となってしまいました。 エラーの要因 過熱商品の特性の変化(商品展開数の増加など) リプレイスPhase1で想定していた以上のアクセス数 想定以上のアクセス数により参照系処理が増加したことによるデータベースのラッチ競合 上記の対策として、アプリケーション、インフラ両軸で複合的な対策が求められますが、具体例として以下を実施しています。 キューのキャパシティコントロール最適化(シャード並列数の再精査) 高負荷時もシステム全体に波及させず影響を最小化するistioを活用したサーキットブレーカーの導入 データベースのRead/Write分離による負荷軽減 Akamaiなど各種セキュリティソリューションの有効活用 過去のistioを活用したサーキットブレーカーの導入事例は以下の記事にまとまっているためご興味のある方はご覧ください。 techblog.zozo.com 網羅的に想定したつもりではあったものの、その想定を大きく超える事象により障害となってしまいとても悔しい結果となりました。「どこまでやるか」はシステム永遠の課題でありスケジュール・コスト等とのトレードオフです。しかし障害対応によるブラッシュアップによって、より盤石なシステム・サービスになると思っています。 まとめ 【結論】やってよかった 最初に組織変更をしたことで、その後のリプレイスを加速できた。逆に言うとそれぐらいの覚悟は必要 問題を正しく理解する前に解決することを急がない 分かっていてもはまりがちなため、何度も自分に言い聞かせた。超大事! 巨人の肩に乗る 先人達のベストプラクティスには理由がある マイクロサービスは特に負荷試験に時間を割く サービス単体では見つけられなかった課題も、プロダクション環境に組み込んで試験することでリリース前に多くの課題を吸収できた 良いシステムはブラッシュアップによって生まれるもの リリース後も常に問題意識を持つことが大事 将来を見据えた設計は重要だが、先を見過ぎない 先を見過ぎるとあれもこれもとなり結果汎用的になりスピード感も落ちてしまう。大事なのは一点突破できる機能 監視コストが下がることで組織文化にも良い影響を与えることができた 一方、BOTによる過剰アクセス対策については日々ブラッシュアップが求められるため、アプリケーション、インフラ両軸で継続的に取り組んでいく必要がある 最後に 本記事はカートリプレイスPhase1の振り返りとなりますが、カート決済サービスのリプレイスは始まったばかりです。 セールやキャンペーンによる突発的な注文フロースパイク 決済サービスのモノリスからの脱却、マイクロサービス化 今後のZOZOTOWN成長にも耐えうる、正確性、信頼性、安全性を重視したスケールできるカート決済サービス モニタリングの洗練化 スピード感、モダンなメインストリーム技術の導入、etc 上記はいくつかの例ですが、まだまだやりたいことが山積みの状態です。このような課題の解消に向けて、一緒に進めていく仲間を募集しています。ご興味のある方は、以下のリンクから是非ご応募ください。 hrmos.co hrmos.co また、カジュアル面談も随時実施中です。是非ご応募ください。 hrmos.co
アバター