10X の推薦を作るチームと ML platform

10X ソフトウェアエンジニアの @metalunk です。ネットスーパー、ネットドラッグストアのプラットフォームである Stailer 事業で、機械学習(ML)と検索を専門として働いています。

2024年4月からいま(2024年8月)までの5ヶ月間で6つの推薦機能をリリースできました。この成果を支えたのはチームと ML platform(機械学習の基盤システム)です。このブログではチームの取り組み、ML platform の機能、および具体的な成果についてご紹介します。

このブログは技術ブログの体ではありますが、さまざまな業界、職種の方に読んでいただくことを目指して執筆しました。

(3) 章, (5) 章だけは機械学習に取り組んでいる人向けの内容を含みますので興味のない方は読み飛ばしてもらって結構です(機械学習に取り組んでいなくても興味のある方はぜひ読んでください)が、それ以外は IT 業界のみならず小売業界の方にも読んでいただけると思います。

  • (1) 5ヶ月間の成果
  • (2) お客さま体験チームの取り組み
    • (2.1) 必要最低限のチーム構成
    • (2.2) デモの活用
    • (2.3) インターリービングテストの活用
    • (2.4) ダッシュボードの活用
    • (2.5) 改善サイクルの重要性
    • (2.6) 推薦だけじゃないお客さま体験チーム
  • (3) Stailer の ML platform
    • (3.1) 前提: ネットスーパー、ネットドラッグストアにおける推薦
    • (3.2) ML platform の機能
      • (3.2.1) ML デモ機能
      • (3.2.2) Serving は Elasticsearch か Firestore
      • (3.2.3) Vertex AI Pipelines の工夫(小ネタ)
  • (4) リリースした推薦機能の紹介
    • (4.1) レジ前推薦でのパーソナライズモデル
    • (4.2) 関連商品推薦
    • (4.3) 一緒にどうぞ推薦
    • (4.4) 次の検索キーワード推薦
    • (4.5) 人気順
    • (4.6) 代替商品推薦
  • (5) 余談: 検索と推薦の技術の境目
  • (6) おわり

(1) 5ヶ月間の成果

はじめに、この5ヶ月間で上げた成果を列挙します(つかみです。詳しくは (4) 章で取り上げます)

  1. レジ前推薦でパーソナライズモデルをリリースし、レジ前推薦の売り上げ 10x(カート追加率 3.2x, カート追加点数 2x, 単価 1.6x)
  2. 関連商品推薦のあたらしいモデルをリリースし、カート追加率 3x
  3. セレンディピティの高い、一緒にどうぞ推薦をリリース
  4. 次の検索キーワード推薦のリリース
  5. 人気順をリリースし、もっともカート追加率が高い並び順に
  6. 代替商品推薦のリリース

これで推薦枠は、レジ前推薦、ランキング、商品詳細、次の検索キーワード推薦、代替商品推薦の5箇所になりました。

そして、これらの成果は、2024年4月に始動したお客さま体験チームによって生み出されました。

続きを読む

GitHubで扱うPersonal access tokenの利用方法をセキュアにする

GitHubで扱うPersonal access tokenの利用方法をセキュアにするのタイトル画像

こんにちは、セキュリティチームの@sota1235です。

セキュリティチームでは昨年の夏頃からGitHub上のセキュリティリスクを洗い出し、順に対応や改善を行っています。

そのうちの1つとして、昨年の秋ごろからGitHubのPersonal Access Tokenの取り扱いの改善を行ってきました。

具体的には以下の取り組みを行いました。

  • CI等で利用されているPersonal Access Tokenの利用廃止
  • OrganizationにおけるPersonal Access Token(classic)の利用禁止設定

今回はこの2つの取り組みについて、どのような課題設定を行い、どんな手順で完了したのかをお話しします。

以下のような課題感、疑問をお持ちの方に対する1つの回答になりうると思うので該当する方はぜひご一読ください🙏

  • GitHubにおけるPersonal Access Tokenとどう付き合うべきかの解像度が低い
  • Personal Access Tokenにまつわるセキュリティ上の対策が知りたい
続きを読む

GKEでArgo WorkflowsにWorkflow Archiveを導入する

データエンジニア業務委託のjcです。 今回は、GKEでArgo WorkflowsにWorkflow Archiveを導入する話を共有します。 Argo Workflowを利用している方・利用検討中の方のご参考になればと思います。

Argo Workflowsとは

Argo Workflowsは、Kubernetes上でJobを実行するためのワークフローエンジンです。 Kubernetesネイティブであるため、リソースの管理やスケールが容易であり、Airflowなどの他のワークフローエンジンとは異なり、別途計算リソースを用意したり、schedulerを設定したりする必要がありません。 ただし、Kubernetesクラスタの管理コストが発生するため、Argo Workflowsが最強のワークフローエンジンであるとは一概には言えません。

argoproj.github.io

Workflow Archiveを使う理由

Kubernetesでは、実行済みのPodを回収するGarbage Collectionメカニズムが実装されています。 過去の実行履歴も確認できるようにするために、Argo WorkflowsではGarbage Collectionを一時的に回避する機能が実装されており、controllerの環境変数 ARCHIVED_WORKFLOW_GC_PERIODで保持期間を設定できます。また、パラメータTTLStrategyを設定することでWorkflowの生存期間を個別に設定することもできます。

一方で、Pod自体のコストやPodを監視するコストなどがかかるため、長期間保存に向いてません。 Workflow ArchiveはArgo Workflowsが提供している機能で、実行済みのワークフローをデータベースに保存することで、UIから過去のworkflow実行履歴一覧を確認できます。

Workflow Archiveの導入方法

Workflow Archive機能を軽く動作確認してイメージを掴みたい場合、公式のquick-start-postgres.yamlをそのままapplyするとPostgreのPodと設定済みのConfigMapがArgo Workflows本体と一緒に作成されるため、最もシンプルだと思います。

github.com

The quick-start deployment includes a Postgres database server. In this case the workflow archive is already enabled. Such a deployment is convenient for test environments, but in a production environment you must use a production quality database service.

Note that IAM-based authentication is not currently supported

Workflow Archive - Argo Workflows - The workflow engine for Kubernetes

ただし、ドキュメントに記載しているように、ステートレスなPodは本番環境に向いておらず、IAMベースの認証にも対応していないため、本番環境ではマネージドデータベースを利用するのが一般的です。 Argo Workflows用KubernetesクラスタがGCPにデプロイされている場合、Cloud SQLを利用すると便利です。しかし、Cloud SQL Auth ProxyとPrivate IPの設定などを含めて、考慮すべき事項が増えます。実際に適用する際に苦労したため、その詳細をこのエントリで紹介します。

続いて、Terraformのリソース作成例とKubernetesのマニフェスト例を挙げながら、詳細なステップを紹介していきます。

Cloud SQLの設定

まずはCloud SQLを作成します。 ワークフローの数と保持期間にもよりますが、この例では最も小さいインスタンスdb-f1-micro利用しています。

本記事はWorkflow Archiveの設定に着目しているため、google_service_networking_connectionを含めてネットワーク周りの設定は割愛します。

data "google_compute_network" "default" {
  name    = "default"
  project = "YOUR-PROJECT-ID"
}

resource "google_sql_database_instance" "workflow_archive" {

  project          = "YOUR-PROJECT-ID"
  name             = "workflow-archive"
  region           = "asia-northeast1"
  database_version = "POSTGRES_15"

  depends_on = [google_service_networking_connection.cloudsql_workflow_archive_vpc_connection]

  settings {
    tier = "db-f1-micro"
    ip_configuration {
      ipv4_enabled                                  = false
      private_network                               = data.google_compute_network.default.id
      enable_private_path_for_google_cloud_services = true
    }
    backup_configuration {
      enabled                        = true
      point_in_time_recovery_enabled = true
    }
    availability_type = "REGIONAL"
  }
}

サービスアカウントの設定

Cloud SQLにアクセスするために、GCP側のサービスアカウント(GSA)とKubernetes側のサービスアカウント(KSA)両方の設定が必要です。

公式のHelm Chartでインストールする場合、server用のKSA argo-workflows-serverとcontroller用のKSA argo-workflows-workflow-controllerが最初から作成されているため、追加でGSAを作成し、CloudSQLへの読み取り権限を付与します。

resource "google_service_account" "workflow_server" {
  project      = "YOUR-PROJECT-ID"
  account_id   = "workflow-server"
  display_name = "workflow-server"
}
resource "google_project_iam_member" "workflow_server_is_cloudsql_client" {
  project = "YOUR-PROJECT-ID"
  role    = "roles/cloudsql.client"
  member  = "serviceAccount:${google_service_account.workflow_server.email}"
}

GSAをKSAに紐付けます。

resource "google_service_account_iam_member" "ksa_workflow_server_becomes_gsa_workflow_server" {
  service_account_id = google_service_account.workflow_server.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "serviceAccount:YOUR-PROJECT-ID.svc.id.goog[workflows/argo-workflows-server]"
  depends_on = [
    google_service_account.workflow_server
  ]
}

さらに、KSAにGSAのアノテーションを追加して、サービスアカウントの設定が完了します。

apiVersion: v1
kind: ServiceAccount
metadata:
  name: argo-workflows-server
  annotations:
    iam.gke.io/gcp-service-account: workflow-server@your-project.iam.gserviceaccount.com

controllerのサービスアカウントの設定はserverと全く同じなので、ここでは省略します。

Cloud SQL Auth Proxyとアクセス情報の設定

サービスアカウントの設定をした後、Cloud SQL Auth Proxyとアクセス情報の設定を行います。 Cloud SQL Auth Proxyを利用すると、静的なIPアドレスを提供する必要がなく、GKEクラスタから直接Cloud SQLのインスタンスに接続できます。 最小権限の原則に従い、Cloud SQLへのアクセス権限はクラスタ全体ではなく、アプリケーションArgo Workflowsのみに付与します。 そのため、SidecarパターンでCloud SQL Auth Proxyを設定します。

controllerとserverのDepolymentに以下sidecarを入れます。(Kustomizationなど利用してpatchを当てる方法が一般的です)

name: cloud-sql-proxy
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.11.0
args:
  - "--private-ip"
  - "--structured-logs"
  - "--port=5432"
  - "YOUR-PROJECT-ID:asia-northeast1:workflow-archive"
securityContext:
  runAsNonRoot: true
resources:
  requests:
    cpu: "1"
    memory: "2Gi"

最終的にserverのDeploymentは以下のようになります。

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app.kubernetes.io/component: server
    app.kubernetes.io/instance: argo-workflows
    app.kubernetes.io/managed-by: Helm
    app.kubernetes.io/name: argo-workflows-server
    app.kubernetes.io/part-of: argo-workflows
    app.kubernetes.io/version: v3.5.7
    helm.sh/chart: argo-workflows-0.41.7
  name: argo-workflows-server
  namespace: workflows
spec:
  replicas: 1
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      app.kubernetes.io/instance: argo-workflows
      app.kubernetes.io/name: argo-workflows-server
  template:
    metadata:
      labels:
        app.kubernetes.io/component: server
        app.kubernetes.io/instance: argo-workflows
        app.kubernetes.io/managed-by: Helm
        app.kubernetes.io/name: argo-workflows-server
        app.kubernetes.io/part-of: argo-workflows
        app.kubernetes.io/version: v3.5.7
        helm.sh/chart: argo-workflows-0.41.7
    spec:
      containers:
      - args:
        - server
        - --configmap=argo-workflows-workflow-controller-configmap
        - --namespace=workflows
        - --auth-mode=server
        - --secure=false
        - --loglevel
        - info
        - --gloglevel
        - "0"
        - --log-format
        - json
        env:
        - name: IN_CLUSTER
          value: "true"
        - name: ARGO_NAMESPACE
          valueFrom:
            fieldRef:
              apiVersion: v1
              fieldPath: metadata.namespace
        - name: BASE_HREF
          value: /
        image: quay.io/argoproj/argocli:v3.5.7
        imagePullPolicy: Always
        name: argo-server
        ports:
        - containerPort: 2746
          name: web
        readinessProbe:
          httpGet:
            path: /
            port: 2746
            scheme: HTTP
          initialDelaySeconds: 10
          periodSeconds: 20
        resources: {}
        securityContext:
          allowPrivilegeEscalation: false
          capabilities:
            drop:
            - ALL
          readOnlyRootFilesystem: false
          runAsNonRoot: true
        volumeMounts:
        - mountPath: /tmp
          name: tmp
      - args:
        - --private-ip
        - --structured-logs
        - --port=5432
        - YOUR-PROJECT-ID:asia-northeast1:workflow-archive
        image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.11.0
        name: cloud-sql-proxy
        resources:
          requests:
            cpu: "1"
            memory: 2Gi
        securityContext:
          runAsNonRoot: true
      nodeSelector:
        kubernetes.io/os: linux
      serviceAccountName: argo-workflows-server
      volumes:
      - emptyDir: {}
        name: tmp

DeploymentにSidecarを追加した後、ConfigMapにWorkflow Archiveの関連設定を追加します。 同じPod内にあるproxyのsidecarにアクセスすれば良いので、hostportおよびdatabaseはデフォルト値で問題ありません。 DBにアクセスするためのusernamepasswordはセンシティブな情報のため、Secret Managerに入れてExternal Secretを利用して取得した方が良いでしょう。詳細は割愛します。

persistence:
  connectionPool:
    maxIdleConns: 100
    maxOpenConns: 0
    connMaxLifetime: 0s
  nodeStatusOffLoad: true
  archive: true
  archiveTTL: 7d
  postgresql:
    host: localhost
    port: 5432
    database: postgres
    tableName: argo_workflows
    userNameSecret:
      name: workflow-archive-db-username
      key: username
    passwordSecret:
      name: workflow-archive-db-password
      key: password

動作確認

最後に動作確認を行います。 Argo WorkflowsのUIにアクセスすると、実行済みのworkflowのARCHIVEDカラムがtrueになり、Pod自体が消えてもWorkflowが表示され続けます。

DBからも実行済みのWorkflow情報を確認できます。

まとめ

本記事では、GKEでArgo WorkflowsにWorkflow Archiveを導入する理由と方法について紹介しました。 ご参考になれば幸いです。

Stailerがスクリーンリーダーに対応するまでの道のり~Flutterでのスクリーンリーダー対応について、あるいはユーザビリティやユーザー獲得の話~

ブログのタイトル画像、Stailerがスクリーンリーダーに対応するまでの道のりとFlutterでのスクリーンリーダー対応について、あるいはユーザビリティやユーザー獲得の話

こんにちは、ソフトウェアエンジニアの@futaboooです。

先日スクリーンリーダーへ対応したプレスリリースを配信しました。今日はその裏側について紹介です。 10x.co.jp

はじめに

とあるパートナーのネットスーパーシステムをStailerへリプレイスして少しすると、お客様から「今まで使えていたのに使えなくなった!」という切実な声が届きました。この問い合わせを通じて、視覚障害者のお客様がスクリーンリーダーを使って買い物をしていたこと、そしてStailerがそのニーズに応えていないことに気づきました。 そこで、我々は視覚障害者のお客様へのヒアリングを開始し、どのような環境でアプリを使っているのか、使用しているデバイスやスクリーンリーダーソフトウェアの種類など、具体的な情報を収集しました。このプロセスを通じて、アプリがより多くの人々にとって使いやすいものになるための重要な改善点を発見しました。 スクリーンリーダー対応を進めることで、プロダクト面では全てのお客様にとって使いやすいアプリとは何かを考えるきっかけとなり、開発面では具体的な実装方法や解決策を探ることができました。この記事では、その詳細を共有したいと思います。

解像度を高める

お問い合わせを受けて視覚障害者のお客様は普段どのようにしてお買い物をしているのか解像度を高める活動を行いました。視覚障害者向けには様々な支援技術が存在しており、スクリーンリーダーもそのうちの一つです。他には画面拡大ツールや配色変更などがあります。OSやブラウザによって支援技術のソフトウェアも様々なものが存在しています。スクリーンリーダーを使用する環境においてはPC環境ではWindowsとChromeを使いサードパーティのPC-Talkerを使うパターンが多く、モバイル環境ではiOSとSafariを使いOS標準のVoiceOverを使うことが多いことがわかりました。

accessible-usable.net

また、実際に視覚障害者の方々へのユーザーインタビューを実施しました。このときのユーザーインタビューではお問い合わせをいただいたお客様がWeb版を使っていたのでWeb版ですることとしました。結果はほぼ読み上げが行なわれずにお買い物を完了することができない状態であることがわかりました。さらに、実店舗へお買い物に行く場合はスタッフに帯同してもらい、補助してもらいながらお買い物をするのである程度妥協するシーンもあり、ネットスーパーでお買い物ができれば気兼ねなく自分のペースで楽しめるということで読み上げへの対応が重要であることがわかりました。

対応方針を決める

ユーザーインタビューで普段買い物ではWebを使っていることや参加してくれた方がみなさんiPhoneだったこと、また先述した日本における支援技術の利用状況から、モバイルブラウザをメインの動作確認対象とし、特にiOS Safariをメインの動作ターゲットとしてお買い物完了に必須となる部分の読み上げに対応することとしました。また対応内容についても最低限とし、体験が良い状態はスコープ外として進めることを決めました。体験についてはスクリーンリーダー対応完了後に再度ユーザーインタビューを経てスコープを決めるのが良いという判断です。

開発は業務委託でスクリーンリーダー対応を依頼できる方を探し、@blendthink さんに副業でご協力いただくことができました。社内のメンバーだけではなく業務委託の方を採用したのは、スクリーンリーダーの対応はドメイン知識よりもFlutterの専門性やアクセシビリティへの理解が必要とされるためFlutterでのスクリーンリーダー対応を進めてもらいながらドキュメントも用意してもらい、一定知見が溜まったところで、対応を拡大するタイミングで社内メンバーも開発に参加し知見をインストールするという進め方で効率よく対応ができると考えたからです。(@blendthink さんにはFlutterの不具合で動かない場面でワークアラウンドを実装していただいたり、Semanticsの動作についてドキュメントを用意いただいたりしました。今回の対応が進められたのはblendthinkさんのおかげです。)

Flutterでのスクリーンリーダー対応

具体的にFlutterでどのようにスクリーンリーダー対応を行ったのかについてここでは紹介していきます。プロダクトとしてどうやって対応を進めたのかについて知りたい方は読み飛ばしてもらっても大丈夫です。

SemanticsTree、SemanticsNodeを理解する

SemanticsTree

FlutterはすべてがWidgetとしてレイアウト情報がTree構造になっていますが、セマンティクス情報もすべてがTree構造になっています。注意が必要なのはWidgetTreeとSemanticsTreeが必ずしも一致しないところです。例えば後述するSemantics Widgetでcontainer=trueを指定すると子どものWidgetのSemantics情報が親のSemantics情報にまとめられて一つのSemanticsNodeとして出力されます。

SemanticsNode

SemanticsTreeを構成する要素です。SemanticsNodeはSemantics Widgetで指定した様々なセマンティクス情報を保持しています。例えばSemantics.labelやSemantics.buttonや自身の子どものSemanticsNodeのリストなどです。他にも様々なセマンティクス情報がありますので公式ドキュメントのSemanticsPropertiesを参考にしてください。

SemanticsProperties class - semantics library - Dart API

スクリーンリーダー対応で知っておきたいWidget

Semantics

Semantics class - widgets library - Dart API

Semanticsを使わないパターン

WidgetとSemanticsのTreeが一致している

      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),

CenterをSemanticsでwrap、container=trueにしたパターン

Column WidgetがCenterに合成されている

      body: Semantics(
        container: true,
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              const Text(
                'You have pushed the button this many times:',
              ),
              Text(
                '$_counter',
                style: Theme.of(context).textTheme.headlineMedium,
              ),
            ],
          ),
        ),
      ),

CenterをSemantics(container=true) 、子どものTextをSemantics(container=true) warpしたパターン

Column WidgetがCenterに合成されているが、Textは独立したSemanticsとして表示される

      body: Semantics(
        container: true,
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Semantics(
                container: true,
                child: const Text(
                  'You have pushed the button this many times:',
                ),
              ),
              Text(
                '$_counter',
                style: Theme.of(context).textTheme.headlineMedium,
              ),
            ],
          ),
        ),
      ),

MergeSemantics

MergeSemantics class - widgets library - Dart API

子どものSemanticsを合成しひとつのSemanticsNodeとします。 例えば以下の例では2つのTextが合成されてYou have pushed the button this many times:$_counterという読み上げが一つのSemanticsNodeとして実行されます。他にも商品画像とタイトルやスライダーとラベルなど合成しても問題ない複数のWidgetをMergeSemanticsで合成することで無駄なSemanticsNodeを作らないことによるパフォーマンス向上やシンプルなSemanticsTreeの作成によってわかりやすい読み上げを実現することができます。

      body: Center(
        child: MergeSemantics(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              const Text(
                'You have pushed the button this many times:',
              ),
              Text(
                '$_counter',
                style: Theme.of(context).textTheme.headlineMedium,
              ),
            ],
          ),
        ),
      ),

ExcludeSemantics

ExcludeSemantics class - widgets library - Dart API

子どものSemanticsをSemanticsTreeから除外します。 ExcludeSemanticsでwrapしなくてもWidgetのpropertyにexcludeFromSemanticsのようなものがある場合もあります。これをtrueにすることでもSemanticsTreeからの除外を実現できます。

excludeFromSemantics property - GestureDetector class - widgets library - Dart API

      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            ExcludeSemantics(
              child: Text(
                '$_counter',
                style: Theme.of(context).textTheme.headlineMedium,
              ),
            ),
          ],
        ),
      ),

BlockSemantics

BlockSemantics class - widgets library - Dart API

z-indexというのが正しいのかわかりませんがBlockSemanticsでwrapするとそのWidgetよりも下に描画されているSemanticsNodeが非表示になります。公式ではDrawerなどがBlockSemanticsを使っています。

      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: BlockSemantics(
        child: FloatingActionButton(
          onPressed: _incrementCounter,
          tooltip: 'Increment',
          child: const Icon(Icons.add),
        ),
      ), 

デバッグ方法

視覚的にSemanticsNodeを確認する

MaterialAppにshowSemanticsDebugger=trueを追加するとSemanticsNodeを視覚的に確認することができます。 これまでのサンプルのキャプチャ画像はこの設定をtrueにして確認したものになります。スクリーンリーダーでのタップ可能領域や読み上げ内容が表示されるようになります。

  MaterialApp(
   showSemanticsDebugger: true,

SemanticsTreeをDumpする

Debug Flutter apps from code | Flutter

debugDumpSemanticsTree()を実行するとそのタイミングで生成されているSemanticsTreeをDumpすることができます。公式にもデバッグのサンプルがあるので参考にしてください。

明示的にSemantics Widgetを使わなかったときのSemanticsTree

I/flutter (21395): SemanticsNode#0
I/flutter (21395):  │ Rect.fromLTRB(0.0, 0.0, 1080.0, 2337.0)
I/flutter (21395):  │
I/flutter (21395):  └─SemanticsNode#1
I/flutter (21395):    │ Rect.fromLTRB(0.0, 0.0, 411.4, 890.3) scaled by 2.6x
I/flutter (21395):    │ textDirection: ltr
I/flutter (21395):    │
I/flutter (21395):    └─SemanticsNode#2
I/flutter (21395):      │ Rect.fromLTRB(0.0, 0.0, 411.4, 890.3)
I/flutter (21395):      │ sortKey: OrdinalSortKey#0e91d(order: 0.0)
I/flutter (21395):      │
I/flutter (21395):      └─SemanticsNode#3
I/flutter (21395):        │ Rect.fromLTRB(0.0, 0.0, 411.4, 890.3)
I/flutter (21395):        │ flags: scopesRoute
I/flutter (21395):        │
I/flutter (21395):        ├─SemanticsNode#6
I/flutter (21395):        │ │ Rect.fromLTRB(0.0, 0.0, 411.4, 80.0)
I/flutter (21395):        │ │
I/flutter (21395):        │ └─SemanticsNode#7
I/flutter (21395):        │     Rect.fromLTRB(16.0, 38.0, 262.4, 66.0)
I/flutter (21395):        │     flags: isHeader, namesRoute
I/flutter (21395):        │     label: "Flutter Demo Home Page"
I/flutter (21395):        │     textDirection: ltr
I/flutter (21395):        │
I/flutter (21395):        ├─SemanticsNode#4
I/flutter (21395):        │   Rect.fromLTRB(61.0, 457.1, 350.4, 477.1)
I/flutter (21395):        │   label: "You have pushed the button this many times:"
I/flutter (21395):        │   textDirection: ltr
I/flutter (21395):        │
I/flutter (21395):        ├─SemanticsNode#5
I/flutter (21395):        │   Rect.fromLTRB(197.8, 477.1, 213.6, 513.1)
I/flutter (21395):        │   label: "0"
I/flutter (21395):        │   textDirection: ltr
I/flutter (21395):        │
I/flutter (21395):        └─SemanticsNode#8
I/flutter (21395):          │ merge boundary ⛔️
I/flutter (21395):          │ Rect.fromLTRB(0.0, 0.0, 56.0, 56.0) with transform
I/flutter (21395):          │ [1.0,2.4492935982947064e-16,0.0,339.42857142857144;
I/flutter (21395):          │ -2.4492935982947064e-16,1.0,0.0,818.2857142857143;
I/flutter (21395):          │ 0.0,0.0,1.0,0.0; 0.0,0.0,0.0,1.0]
I/flutter (21395):          │ tooltip: "Increment"
I/flutter (21395):          │ textDirection: ltr
I/flutter (21395):          │
I/flutter (21395):          └─SemanticsNode#9
I/flutter (21395):              merged up ⬆️
I/flutter (21395):              Rect.fromLTRB(0.0, 0.0, 56.0, 56.0)
I/flutter (21395):              actions: tap
I/flutter (21395):              flags: isButton, hasEnabledState, isEnabled, isFocusable
I/flutter (21395):              thickness: 6.0

BlockSemanticsを使ったサンプルのときのSemanticsTree

I/flutter (20990): SemanticsNode#0
I/flutter (20990):  │ Rect.fromLTRB(0.0, 0.0, 1080.0, 2337.0)
I/flutter (20990):  │
I/flutter (20990):  └─SemanticsNode#1
I/flutter (20990):    │ Rect.fromLTRB(0.0, 0.0, 411.4, 890.3) scaled by 2.6x
I/flutter (20990):    │ textDirection: ltr
I/flutter (20990):    │
I/flutter (20990):    └─SemanticsNode#2
I/flutter (20990):      │ Rect.fromLTRB(0.0, 0.0, 411.4, 890.3)
I/flutter (20990):      │ sortKey: OrdinalSortKey#aa8f5(order: 0.0)
I/flutter (20990):      │
I/flutter (20990):      └─SemanticsNode#3
I/flutter (20990):        │ Rect.fromLTRB(0.0, 0.0, 411.4, 890.3)
I/flutter (20990):        │ flags: scopesRoute
I/flutter (20990):        │
I/flutter (20990):        └─SemanticsNode#4
I/flutter (20990):          │ merge boundary ⛔️
I/flutter (20990):          │ Rect.fromLTRB(0.0, 0.0, 56.0, 56.0) with transform
I/flutter (20990):          │ [1.0,2.4492935982947064e-16,0.0,339.42857142857144;
I/flutter (20990):          │ -2.4492935982947064e-16,1.0,0.0,818.2857142857143;
I/flutter (20990):          │ 0.0,0.0,1.0,0.0; 0.0,0.0,0.0,1.0]
I/flutter (20990):          │ tooltip: "Increment"
I/flutter (20990):          │ textDirection: ltr
I/flutter (20990):          │
I/flutter (20990):          └─SemanticsNode#5
I/flutter (20990):              merged up ⬆️
I/flutter (20990):              Rect.fromLTRB(0.0, 0.0, 56.0, 56.0)
I/flutter (20990):              actions: tap
I/flutter (20990):              flags: isButton, hasEnabledState, isEnabled, isFocusable
I/flutter (20990):              thickness: 6.0

Webの対応

SemanticsTreeの生成を強制する

AndroidアプリやiOSアプリの場合はTalkbakやVoiceOverのスクリーンリーダーソフトを立ち上げるとSemanticsTreeが生成されて読み上げ可能になりますがWebでは少し工夫が必要です。具体的には以下の1行を追加してあげる必要があります。

SemanticsBinding.instance.ensureSemantics();

実際の実装などは公式を参考にしてください。

flutter/dev/a11y_assessments/lib/main.dart at 778eaf6c1dcaba7a433a66cbce25385d9db7877d · flutter/flutter · GitHub

この1行を追加しないとWebでアクセスした時にenable accessibility buttonという読み上げがされる透明なボタンにまずフォーカスがあたり、これを押さないとSemanticsTreeが生成されずサイト内の読み上げされないという状況になってしまいます。このボタンの読み上げは日本語対応されていないので初見では何が起こるのかわからないため強制的にSemanticsTreeを生成する先程のコードを追加しました。

ただしこの設定をONにするとiOSアプリでは特定のOSバージョンでTextFiledがつぶれてしまうバグに遭遇したのでWebに限定するのを忘れないようにします。

Webだけ設定が必要なのは以下のイシューが関連していると思いますが裏取りできていないのでもし知っている方が居たらコメントお待ちしております。

[a11y][Web] MediaQuery.accessibleNavigation is not updating when a11y is enabled · Issue #134980 · flutter/flutter · GitHub

今後のスクリーンリーダー対応について

ガイドラインの導入

スクリーンリーダー対応ガイドラインを作成して実装上判断に迷う部分はガイドラインを参考にすることで迷わず実装が進められるようにしています。例えば・や/のように要素を分ける意図の記号は「、」(読点)として読み上げさせるというのをMUSTとしてガイドラインには定義しています。これはWindowsの読み上げソフトのPC-Talkerの場合、読点を分節の区切りとして読み上げ時に1拍置いてくれるので聞き取りやすくなるためこのように対応することをMUSTとしています。まだまだ実装の知見をガイドラインにしきれていないので今後も整備を続けていきたい思います。

Widget Testの導入

せっかくスクリーンリーダーに対応してもUIが変わるときに対応を忘れたり、新規画面では対応が漏れたりすることもあります。スクリーンリーダー対応が動いているかどうかは自動テストととても相性が良いのでWidget Testを導入し今回対応した読み上げを維持していけるようにしていきたいと思っています。

スクリーンリーダー対応開発後のユーザーインタビュー

しばらくの開発期間を経てもう一度ユーザーインタビューをしてもらっても大丈夫という段階で、前回ユーザーインタビューに参加してくれた方々へ改善後のStailerを使ってもらいました。 前回はほとんど読み上げがされずお買い物を完了するこができませんでしたが、今回は参加いただいたみなさんにお買い物が完了するところまで進めてもらうことができました。

また、今回のユーザーインタビューで嬉しい誤算もありました。スクリーンリーダー対応着手前にはWebでの利用を想定して対応を進めており、文字入力の部分にかなり苦労して対応を進めていました。しかしインタビューの中で「アプリはスクリーンリーダーに未対応のものが多くWebの方がまともに使えることが多いのでWebを使っていることが多い。アプリが使えるならそっちのほうが嬉しい」と言っていたのを聞き、アプリを触ってもらうと「これだけ読み上げられるならアプリを使う、知り合いにも紹介したい」という感想をいただくことができました。Flutter on the Webでの読み上げはアプリに比べると想定通り動かないことがあったり、入力に不備があったりするのでアプリを使ってもらえるのは我々としてもWebへ対応するよりも工数を減らすことができます。

おわりに

リプレイス後のお問い合わせを発端としてスクリーンリーダーの対応を行ってきました。最終的にユーザーインタビューでとてもうれしい言葉をいただけるところまで対応することができました。実際にお問い合わせいただいたお客様のもとにも届いて、またお買い物ができるようになっていたら良いなと思っています。

今回の対応は視覚障害者の方のアクセシビリティ向上の一面が大きいですが、ユーザーインタビューの最後にもあったように新たなお客様を獲得する活動ととらえることもできます。多くの人が使いやすいサービスを作るためにアクセシビリティを常に意識した開発をしていきたいと思っています。

GKE CronJobとcloud-sdk-goでElasticCloudのスケーリングを自動化しコスト削減した

はじめに

こんにちは、検索エンジニアの安達(id:kotaroooo0)です。 10Xで検索基盤・検索機能の開発運用をしています。 最近は推薦システムの開発もちょっとやり始めました。

負荷に合わせてElasticsearch(ElasticCloud)をスケーリングする機能を作りコスト削減したので、その取り組みについて経緯と内容を紹介します。

前提

背景と課題

10Xでは小売チェーン向けECプラットフォームStailerにおいて、検索機能の開発運用にElasticsearchを利用してしています。 Elasticsearchクラスタの基盤にはElasticCloudを採用していますが、ディスク利用量に応じたオートスケール機能しかなく、メモリ利用量やCPU利用量に基づくオートスケーリングはできません。 そのため、負荷が少ない時間帯でもピークタイムのリクエストを処理できるクラスタを稼働させる必要がありました。

また、日に日に負荷が増え続けている影響で、最近インスタンスサイズをスケールアップしないと安定した運用ができなくなりました。 これによりコストが約2倍になり、何らかの対応が必要になりました。

対応方針

リソース利用率に合わせてElasticsearchをスケーリングすることで解決しました。 他の打ち手の候補として、データ更新の時間をずらす、データ更新の速度を落とす、インデックスのデータ構造・設定の変更がありましたが、調整によるリードタイムや工数が大きかったり、効果の不確実性が高かったため見送りました。

StailerのElasticsearchに関する特性

StailarのElasticsearchはCPU boundです。 検索リクエストのピークタイムが8:00 - 12:00、更新リクエストのピークタイムが9:30 - 12:00で重なっており、結果的に CPU利用率 のピークタイムは 9:30 - 12:00になっています。 細かいデータ更新は常にあるものの、スーパーやドラッグストアの在庫データを一括で更新するバッチが日次かつ定時で動いており、更新リクエストのピークタイムは固定です。

以下はCPU利用率のメトリクスです。 縦はCPU利用率、横は時刻です。

検索・更新リクエストのピークタイムが被る9:45~あたりからCPU利用率が急上昇します。

要件

スケールイン・アウトではなくスケールアップ・ダウン

ノード数を増減させるスケールイン・アウトではなく、ノードスペックを増減させるスケールアップ・ダウンを採用しました。

理由

  • ElasticCloudは2(+1 TieBreaker) or 3台構成しかできない(低可用性なら1台でも可)
    • シャード・レプリカ構成上、ノード数を2~3と変動させるとややこしい
      • 負荷・ディスク占有が不均一になる可能性がある
      • シャードの再編成が走りデータ通信量が大きくなりコストがかかる

リソース負荷トリガーではなくスケジュールトリガー

スケジュールトリガーを採用しました。

理由

  • スケジュールトリガーの方が開発工数が小さい
  • StailerのElasticsearchの特性
    • 更新リクエストのピークタイムは固定、検索リクエストのピークタイムもほぼ一定のため
    • データ更新バッチが走り始めると一気にCPU負荷が高騰するため、CPU負荷が高騰に合わせてスケールアップするより事前にスケールアップしておいた方が好ましい
      • ElasticCloudのスケールアップは1台ずつクラスタから切り離されスケールアップされていく
        • 通常3ノードでリクエストを捌くところ一時的に2ノードでリクエストを捌く必要がある
          • そのため、高負荷時にスケールアップするとさらに負荷が高騰するリスクがある

設計

cloud-sdk-goを利用したGoスクリプトをDocker ImageにしGKE CronJobで実行し、定期的なスケールアップ/ダウンを実施しました。

アーキテクチャ図は以下です。

他の選択肢とPros/Consは以下です。 #2を採用しました。

# スケール方法 定期実行方法 Pros Cons 選択候補になった理由
1 Terraform + GitHub ActionsでPull Requestマージ GitHub Actions • 宣言的に管理
• Extensionsもコード管理できコンソールでの手作業が減る
・現状ElasticCloudのTerraform管理していないし、PR自動作成&マージは工数大
・GutHub ActionsのSLAに不安あり
クラスタのスケールアップ・ダウン方法はTerraformが一番良いと考えた。Pull Request作成&マージはGitHub Actionsが親和性が高い。
2 cloud-sdk-go GKE CronJob ・既存で多くCronをGKEで動かしており運用しやすく可用性も安心
・Go(プログラミング言語)で操作できる
・Go用のDocker ImageのBuild/Deployを実装する必要あり クラスタのスケールアップ・ダウン方法はTerraformの次点でcloud-sdk-goと考えた。GitHub Actionsと親和性がない方法にするのであれば別の定期実行方法にしたい。
3 cloud-sdk-go Cloud Functions + Cloud Scheduler ・CloudSchedulerはマネージドであり可用性が高い
・モニタリング・監視もしやすい
・Go(プログラミング言語)で操作できる
Cloud FunctionはGoランタム対応
・CloudSchedulerはこれまで利用されておらず、管理対象が増える
・処理時間の要件上、Cloud FunctionsをHTTP経由でフックするしかなくその場合デプロイが複雑
・長時間Functionsを動かす必要がありGoogleが期待するFunctionsの用途からずれていそう
同上

スケールアップが失敗すると、検索機能に障害が発生する可能性が高く、高い可用性が必要です。 しかしGitHub ActionsでのCronはこれまでの実績的に信頼が低いため、#1は選びませんでした。

また、当初Cloud Scheduler → Pub/Sub → Cloud Functionsも選択肢に入れていましたが、timeoutが最大10分なので選択肢から棄却しました。 スケーリングは10分以上かかってしまうので、成功確認できず失敗した時の検知やリトライの仕組みを別途作る必要がありました。

cloud-sdk-goでの処理

処理の流れは以下です。

注意すべき点は2点あります。

  • ノードのスケールダウン時はディスク容量も小さくなるので、スケールダウン後のディスク容量がパンパンになりすぎないかチェックする必要があります。ElasticCloudではディスクがパンパンになるとインデックスが自動でクローズしノードが機能しなくなることがありました。同様にスケールアップするときもディスク容量に一定の空きが必要です。おそらくシャードのコピーが走るためかと思います。
  • スケールプロセスでは既存のElasticsearchクラスタの設定をGET -> 設定の中のメモリ部分を書き換え -> 書き換えた設定をPUTという処理が必要になります。ここが複雑であるため、生APIを使ったシェルスクリプトでなく、cloud-sdk-goを使うと嬉しい点です。

モニタリング

スケールアップが失敗すると、弱いスペックのノードのまま高負荷状態を迎えることになります。 これによりElasticsearchがパンクし検索機能が動かなくなり、大きなユーザー影響につながるかもしれません。

CronJobでリトライを設定しJobの失敗はリトライするようにし、複数回失敗した場合にはDatadogで検知しSlack + PagerDutyで通知するようにしました。 実際に、リリースしてから3日後にElasticCloudの偶発的な問題によりスケールアップが失敗しリトライが走ることがありました。 リトライを設定しておいて良かったです。

おわりに

本施策により、本番Elasticsearchクラスタのコストを40%程度削減できました。 以下のグラフは、縦軸がElasticsearchクラスタごとのコスト、横軸が時間です。

緑の折れ線が対象のElasticsearchクラスタです。 元々のコストが左の区間、その後スケールアップしコスト増加したのが中央の区間、本施策導入後が右の区間です。 中央の区間で一時的にコストが下がっているのは、別の施策を試してみたが上手くいかなかったので戻した痕跡です。

また、本施策導入後もサービス品質を落とさずに運用できています。

余談ですが、Elasticsearch 7.17から8.12へアップデートも最近実施したのですがパフォーマンスが向上しさらにインスタンスサイズをスケールダウンできコスト削減できました。 おまけにレイテンシーも良くなりました。

ディメンショナルモデリング勉強会を実施しました

データ基盤チームに所属しているデータエンジニアの吉田(id:syou6162)です。10X社内のデータマネジメントの仕事をしています。

最近、社内でディメンショナルモデリング勉強会を行なったですが、なぜ勉強会を行なったのか、どのように行なったのか、勉強会を行なった結果何が得られたかについてまとめます。

ディメンショナルモデリング勉強会開催の背景

前回のエントリにまとめた通り、10Xのデータマネジメントの課題の中でも「データウェアハウジングとビジネスインテリジェンス」は優先度が高いです。意思決定を支えるデータをデータユーザーに提供するため、以前からファクトテーブルやディメンションテーブルといったディメンショナルモデリングでモデリングされたテーブルを提供していました。

しかし、チームの半期の振り返り会でディメンショナルモデリングに対するメンバー間の理解度にばらつきがあるという課題が出ました。より具体的に書いてみると

  • ディメンショナルモデリングに慣れていないメンバー(例: 著者である吉田)がレビューすると、表面的な内容しかレビューができていない
  • ディメンショナルモデリングに慣れていないメンバーがファクトテーブルやディメンションテーブルを書くと、勘どころをを押さえきれておらず、Pull Requestのマージまで時間がかかる
    • 勘どころがなぜ重要なのかを理解しきれておらず、レビューで再度同じ指摘を受けてしまう
  • ディメンショナルモデリングに対する理解度が低いため、データ品質も低くなっている場合があった
    • 例: ファクトテーブルとディメンションテーブルのJOINで必要な外部キーが提供できていない、外部キーは提供されているが命名規則がバラバラ、本来は露出する必要のないナチュラルキーが露出しているなど、データユーザーに対する認知負荷が高くなっていた
    • 例: 外部キーの作り方に一貫性がなく、conformedになっていない外部キーも一部存在した

といった課題が存在しました。これらの課題を解決するための方法の一つとして、ディメンショナルモデリング勉強会を行なってみることにしました。

勉強会の進め方やスコープ

勉強会の初期の予定ではKimball GroupのDimensional Modeling Techniquesの読み合わせを行なう予定でした。Kimball Groupはこの分野では本家的な存在であり、ディメンショナルモデリングの知見に対するカバレッジも高いです。しかし、勉強会を少しやってみて思ったこととしては「よいリファレンスやポインタになっており、すでに知識がある人の辞書としては使いやすい。しかし、経験がない人にとっては、具体例が少なく理解が難しい」ということでした。

そこで、ぺいさんの書かれた記事をベースに勉強会を行なう形に切り替えました。具体的には以下のエントリを参照しながら進めました。

内容としてはStar Schema: The Complete Referenceがベースになっているエントリではあるので、より広く詳細に知りたい方は書籍を読むのがよいと思います。

上述のぺいさんのエントリは典型的なディメンショナルモデリングの本で扱われる内容の1/3~1/2のスコープに留まります。しかし、ディメンショナルモデリングを実務で行なう上ではこのスコープでも一定カバレッジとしては担保できており「広めにやって急ぎ足になるより、基礎を十分固めてチーム内の理解度を高めていきたい」と考えた結果、このスコープで勉強会を行なうことにしました。

なお、Slowly Changing Dimensionsもディメンショナルモデリングにおいて欠かせないトピックの一つです。10Xではデータ基盤の基礎の部分にData Vaultを採用しており、履歴データの管理はData Vaultで担保できている側面も大きかったため、今回の勉強会のスコープからは除外しました。

勉強会の参加者

参加の対象者は社内のDWHやデータマートを作るアナリティクスエンジニアを主に対象にしましたが、DWHやデータマートを使うことが多いアナリスト、(ディメンショナルモデリングを直接使うわけではないが)商品マスタを業務で作るデータエンジニアなどが参加してくれました。毎回5~10人程度が参加し、オンライン(Google Meet)で開催しました。

通常業務もあるため、勉強会の参加者には予習を求めないスタイルで行ないましたが「ファシリテーターがきちんと道案内しないと、議論が深まらない」と感じました。そのため、アナリティクスエンジニアであり、ディメンショナルモデリングにも造形が深い@tenajimaと私*1が事前に読み合わせを行ないました。読み合わせでは、内容の確認もしつつ

  • この事例は自社のリポジトリや分析事例でいうと、どこに該当するだろうか?
  • 自社でも同様の課題感や問題になった事例はあるだろうか?

というような自社事例をこれでもかと豊富に扱うことを心掛けました。以前から社内でファクトテーブルやディメンションテーブルを提供こそしていましたが、きちんとしたディメンショナルモデリングにはまだまだできていません。そのため「現在のモデリングにどういった課題感があるかを再認識してもらいたい」という意図で、自社事例を多く扱うスタイルにしました。

勉強会で学んだ内容

Four-Step Dimensional Design Process

ディメンショナルモデリングを実践する上である意味一番重要とも言えるパートです。Dimensional Design Processは以下の4つの過程で定義されています。

  • 1: Select the business process
  • 2: Declare the grain
  • 3: Identify the dimensions
  • 4: Identify the facts.

1のSelect the business processは特に重要で、業務システムと分析システムはそれぞれシステムの目的が異なります。業務システムから出てくるデータをなんとなくファクトテーブルとディメンションテーブルに切り分けただけでは、成果物がデータユーザーにとっても使いにくいものになってしまいますし、後からの保守運用も難しくなってしまいます。

勉強会ではStailerでは「そもそもどういったビジネスプロセスがあるのか」を含め、tenajimaから以下の内容を説明してもらいました。

  • (あまり整理されていない状態ながらも)過去のDWHで作られていたテーブルにどのようなビジネスプロセスが含まれているか、それをどう整理したか
    • 特に販促関連はスプレッドシートからアドホックなクエリで分析されることも多く、どういったビジネスプロセスがあるか分からなかったため、スプレッドシートを読み解きながら識者にヒアリングを行なった
      • 開発チームが作成したクリティカルユーザージャーニー(CUJ)なども参考にした
    • データ基盤チームは特に見なければいけない領域が広く、一つのドメインはそれに内包されるビジネスプロセスへの理解は浅くなりがちなため、ドメインエキスパートからどのようにヒアリングを行ない、ビジネスプロセスとして整理をしていっているかは特に参考になりました
  • 異なるビジネスプロセスは異なるファクトテーブルに分割する
    • 参考にしたエントリでは注文と出荷が単一のファクトテーブルにすることに弊害が説明されていましたが、自社でも同様の問題が起きていました
    • 特に注文に関するテーブルはデータソースになるテーブルが単一のテーブルになっていたことから、ファクトテーブルも単一のテーブルになっており、配達が完了したものやキャンセルがあったものなど複数のビジネスプロセスが混じっていました
    • そのため、特定のビジネスプロセスの集計をしたい場合にはクエリでフィルタの条件を書く必要があったり、特定のビジネスプロセス以外では全てNULLになるカラムが存在する、などデータユーザーにとっても認知負荷が高い状態になっていました
    • また、単一のファクトテーブルが複数のビジネスプロセスを含んでいたため、開発者としてもクエリが難しくなりがちでメンテナンス性にも問題があるテーブルになっていました
    • それを解決するために、どのようなビジネスプロセスおよびファクトテーブルに分割していったかの説明がされました

今回の勉強会では触れませんでしたが、GitLabのこちらの資料も分かりやすくまとまっていますね。

2のDeclare the grainについても、社内の実例を踏まえてtenajimaから説明してもらいました。社内の例では、注文に対するレコード内で、注文された商品の情報がBigQueryのARRAYSTRUCTの形でデータソースには入っており

  • レコードを粒度を変えずにまとめた形で保持するのか
  • UNNESTを使って、粒度をさらに細かくしたものに分割するのか

について、それぞれのメリット / デメリットについて議論しました。ディメンショナルモデリングでは基本的に粒度は最小粒度で宣言することが推奨されます。しかし、最小粒度である注文された商品一つ一つに対して配送料が定まるわけではなく、注文の合計金額や注文に対して適用されたクーポンの有無など複合的に決まるケースもあります*2。こういった事例を通じて、基本的には粒度は最小粒度で宣言するものの、どの粒度にどういった情報を付与するのがよいか、といった議論を深めていきました。

キーの設計について

ディメンショナルモデリングでは、ナチュラルキーだけでなくサロゲートキーを適切に作る / 使うことが重要になります。データ分析の文脈で、お客様情報のステータスや店舗名の変更といった履歴の管理を行なう必要がある際、サロゲートキーは重要な役割を果たします。また、前述したように10Xではデータ基盤の基礎の部分にData Vaultを採用しており、ここではハッシュキーが登場します。

3つのキー(ナチュラルキー / サロゲートキー / ハッシュキー)を適切に利用できていればよいのですが、残念ながらそうなっていませんでした。ファクトテーブルやディメンショナルテーブルにハッシュキーが露出する必要は本来ないのですが、データマートをData Vaultとファクトテーブルを組合せて作っているケースが存在しており、妥協の結果、ファクトテーブルやディメンショナルテーブルにハッシュキーが露出してしまっているケースがありました。その結果、データユーザーにとっては本来見る必要のないキーが存在することになり、JOINのミスや認知負荷が高い状態になっていました。

勉強会の中ではどのレイヤーでどの種類のキーがインプット / アウトップトになり、どの種類のキーが露出してはいけない(隠蔽する必要があるか)について改めて整理しました。

複数スタースキーマを適切に利用し、ファントラップを避ける

Dimensional Design Processを適切に行なった結果、単一のファクトテーブルではなく複数のファクトテーブルが存在するのはよくあることです(複数スタースキーマ)。複数プロセスをまたいだ分析をする場合、複数のファクトテーブルをまとめて分析する必要が出てきます。10Xではファネル分析などでこういったケースが実際によく現われます。複数のファクトテーブル同士をJOINし、その後GROUP BYして分析する、というのが素直なやり方の一つですが、このやり方には2つの罠が存在します。

1つ目の罠がファントラップです。ファクトテーブル同士をJOINで1:Nの関係になっている場合、レコードが重複し集計値が意図せず増えるケースが起こり得ます(=ファントラップ。書籍によってはfanoutと呼ばれることも)。また、ファネルのように複数個のファクトテーブルをJOINする場合、どこでファントラップが起きているかをデバッグするのは大変でもあります。

2つ目の罠はコスト面です。GA4をデータソースにしたファクトテーブルなどの場合、1つ1つのファクトが大きくなる傾向にあります。巨大なテーブル同士をJOINするとBigQueryのスロットを大量に消費してコストがかかったり、後述する適切なクエリを書けば10分以内に終わるはずの処理が1時間経っても終わらず可用性の面でも問題がある、といった例を具体的なPull Requestを見ながら議論しました。

こうした罠を避けるための方法としては、ドリルアクロスと呼ばれる方法があります。単一のファクトで集計をそれぞれ行ない、粒度を合わせて細かいテーブルになった段階で初めてJOINを行なうというものです。私自身、この方法はBigQueryのベストプラックティスでも紹介されている内容だったため把握はしていましたが、ディメンショナルモデリングの文脈でこうした名前が付いているのを初めて知りました。また、ファクトテーブルやディメンショナルテーブルを使って分析を行なうアナリストも参加してくれていたので、こういった問題を言語化したり「ドリルアクロス」という共通の名前で認識合わせすることもできました。また、ドリルアクロスする場合としない場合の可読性の善し悪しを議論できたのもよかったと思います。

コンフォームドディメンション

入力となるデータソースの種類が増加するにつれ、ファクトテーブルやディメンショナルテーブルは一定の割合で増加するものだと思います。そうなってきた場合にディメンションの間の互換性が問題になってきます。キーの構成方法に微妙に違いがあったり、ディメンションの入力となる値が異なる、などのケースです。こういったディメンションに一貫性がないケースが頻発すると、JOINが意図通りにできなかったり、意図した結果を得るためにアドホックなクエリを書く必要が出てくるとった問題が出てきます。こうした問題はディメンショナルモデリングの旨みをかき消してしまうこともあるため、そうならないように(=コンフォームド)にしていく必要があります。

勉強会では既存のモデリングの中でコンフォームドになっていなかったケースなどを共有し、どうやったらコンフォームドディメンションであることを担保 / 確認*3しやすくなるか、といったことを議論しました。特にStailerではデータの種類が今後増えてくることも想定されるため、今の時点でこういった議論ができたのは有益だったなと思います。

まとめ: 勉強会で得られたもの

このエントリでは、ディメンショナルモデリング勉強会を行なった背景やそのやり方、どのような内容を議論したかについてまとめました。一参加者として、以下の点でこの勉強会をやってよかったなと思いました。

  • 課題感を感じていたメンバー間の知識のギャップをある程度埋める(ベースラインを引き上げる)ことができた
  • 勉強会をきっかけに「今のファクトテーブルって何でこうなってるんでしたっけ?」といったそもそも論の議論をすることができた
    • 歴史的経緯でこうなってしまっており、ToBeの姿とはずれているため、将来的にこうしたいと思っている、といった認識合わせをすることができた
  • 複数の職種で議論する際の共通言語ができた
    • 浸透しきったかというとまだまだですが、ありがちな問題に実は名前が付いていることが知れたり、今後の業務の中で参考にできるポインタがアナリティクスエンジニアやアナリストの間で握れたのもよかった
  • 新メンバーのオンボーディングがやりやすくなった
    • 組織体制の変更に伴ない4月から新しくチームメンバーが増えたが、ペアプロを行なう際に「これはこの間の勉強会で話していたXXXですね」といった形で参照するポインタができるようになった
    • 勉強会の様子は録画しており、後からでも議論の内容が復習しやすいようにしていたのもよかった

ディメンショナルモデリング自体はもう数十年前に提唱されたものですが、最近のモダンデータスタックだけでなくこういった基礎も定期的にチーム内の勉強会で継続的にやっていこうと思います。

*1:ディメンショナルモデリングの知識がまだ薄い想定参加者のつもりで質問

*2:もちろん、商品の体積や重量などを考慮して配送料を商品毎に按分したい、というケースもあると思います

*3:dbtのテストなどを通じて

データマネジメント成熟度アセスメントを実施しました(2024年版)

データ基盤チームに所属しているデータエンジニアの吉田(id:syou6162)です。10X社内のデータマネジメントの仕事をしています。

10X社内では2022年10月にデータマネジメント成熟度アセスメントを実施していましたが、それから約一年半が経過し、データマネジメント上の課題が進捗 / 変化した箇所が出てきました。そこで、最近の成果を振り返りつつ今後のデータマネジメントの方針を改めて見直すため、データマネジメント成熟度アセスメントを再度行なうことにしました。本エントリではその内容についてまとめます。

前回のデータマネジメント成熟度アセスメントへの取り組み

  • データマネジメント成熟度アセスメントとは何か?
  • 2022年10月実施した際のアセスメントの結果
  • アセスメントを実業務にどのように生かしているか

については過去の資料にまとめているので、そちらを参照してください。

「そもそもデータマネジメントとは?その重要性はどういうものがあるの?」という方はDMBOKデータマネジメントが30分でわかる本を読むことをオススメします。前回および今回の成熟度アセスメントもこれらの本をベース / 参考にしながら行なっています。

今回のデータマネジメント成熟度アセスメントのやり方

前回(2022年10月)のデータマネジメント成熟度アセスメントでは、私(吉田)が入社直後だったということもあり、オンボーディングを兼ねてほぼ全職種にヒアリングしました。これ自体は当時の10Xのデータマネジメントの成熟度に対する解像度を飛躍的に高めることに寄与しました。しかし、これを毎回行なうには工数がかかり過ぎるため、今回(2024年3月)は以下のようにコンパクトに実施しました。

  • データマネジメントの11項目に渡って、吉田が成熟度アセスメントの叩き台を作成
    • 各項目につき、notionで1ページ程度のサマリー。以下の3つについて記載する
      • 1: 現在でのレベル感
      • 2: 今後の優先度
      • 3: 各項目のDMBOKでの簡単な概要
        • データマネジメント成熟度アセスメントを初めて行なうメンバーもいるため
  • 叩き台を元に、参加メンバーに各項目の「現在でのレベル感」と「今後の優先度」を入力してもらう
    • レベル感や優先度にギャップがあれば詳細を深掘りし、認識を合わせていく
    • 各項目10~12分程度でサクサク進めていく
      • 初回であればこのペースは難しいと思いますが、成熟度アセスメントをすでに一度実施していると大分やりやすくなっていると感じた
  • 参加メンバーは部内メンバーの3名とデータマネジメントに興味を持つアナリスト2名(任意参加)で実施

成熟度アセスメントの各項目の記入およびディスカッションで2時間、全体のまとめで1時間の合計3時間で実施しました。

成熟度アセスメントの実際の結果

全項目を記載すると膨大な量になってしまうため「前回実施時との差分が大きかった項目」や「優先度が高かったにも関わらずあまり進まなかった項目」についてダイジェストを記載します。

データマネジメント成熟度アセスメントの実施結果の全体像

前回実施時との差分が大きかった項目

データセキュリティ

データセキュリティは確実な進捗がありました。前回実施時点では、データに対する権限は広すぎる & 強すぎる権限付与が行なわれていたり、権限付与も手動によるオペレーションで行なわれていました(前回のレベル感1.5)。しかし、Stailerの利用拡大に伴ないパートナー数も増加しており、パートナー毎の権限管理の強化の必要性が増していました(前回の優先度5)。

これを改善するために、以下の取り組みを実施しました。

  • 誰がどのデータにアクセスしてよいかのポリシーを整備
  • IaCによるコード管理、レビューの必須化、機械による権限付与
    • Conftestによる必要な項目の入力必須化の基盤構築をSREが行なってくれたため、特にBigQueryに関してリソースに関してdescriptionやownerなどのlabelsの情報の入力必須化の実施
    • 新規の権限付与はTerraformで行なうことを徹底
    • terraform importを使って、既存の権限付与もIaCで管理に取り込む*1
  • データエンジニア / アナリティクスエンジニア向けのTerraformを使った権限管理の勉強会の開催し、Terraformに対する敷居を下げる
  • 個人データをより安全に取り扱えるようにするため、仮名加工化の実施
    • 法務担当や外部の法律事務所の先生のアドバイスをもらいながら進行

これらの結果、状況をかなり改善することができました(レベル感3.5)。前回よりは優先度を下げるもののとはいえ、データセキュリティは事業の根幹を支える項目でもあるため、引き続き既存の仕組みの整備などを含めて一定のリソースをかけていく必要があるというディスカッションをしました(優先度3.5)。

データ品質

データ品質は前回の成熟度アセスメントの優先度が4と、積極的に取り組みたい項目の一つでした。その理由としては

  • データに関する問い合わせが特に多く、対応工数がかかっていた
  • DWHやデータマートにテストやdescriptionが書かれていないことも多く、問い合わせに対する調査コストが大きくなりがち
    • 何が担保できているかが明らかではない状況
    • メンテナンスがきちんとできていない古いデータパイプラインによって生成されるデータが参照され続けていることも多かった
    • DWHでは管理されていないBIによるカスタムクエリも多数存在

などがありました(レベル感1.5)。

こういった状況を解決するために、以下の取り組みを行ないました。

  • 古いデータパイプラインの撤退
    • メンテナンスがきちんとできていないパイプラインが2系統存在していたため、データ利用者とのコミュニケーションを取りながら撤退を進めた
    • これは非常に根気が必要で、アナリティクスエンジニアである@tenajimaが粘り強く取り組んでくれた成果
  • カスタムクエリの撲滅
    • BIでカスタムクエリを書けることは自由度を上げるが、データの品質を下げる要因になりやすい傾向があった
    • Tableauの導入に伴ない、担保できる品質によってBIを使い分ける方針を決定し、カスタムクエリを基本的に使わない方針を採用
    • 具体的にはTableauはcertificateされた公式のダッシュボード、Looker StudioやConnected Sheetはアドホックな分析(こちらはカスタムクエリを許容する)という形
  • データ品質の定義や可視化
    • データ品質向上に向けた全体像や指標の定義を部長である@kazk1018が行ない、その実装を吉田が行なった
    • 特に可視化の詳細についてはTokyo dbt Meetup #8で発表した資料を公開しているので、そちらを参照してください
    • 可視化の結果を元にどこに何のテストを書くべきかというToBeの策定に繋げることもできた

以上のように、一定の進捗があったため優先度は少し下げつつ(優先度3.5)、データ品質の可視化結果を参照しながら必要な品質とのギャップを埋めるためのDWH構築(特にDimentional Modelingの拡充)やデータ品質改善のライフサイクルを回していくことを今後は進めていく予定です(レベル感3)。

メタデータ

一番進捗した項目はメタデータでした。前回はメタデータはほぼ何もできておらず(レベル感1)、進んでやる予定もない(優先度1.5)状況でしたが、前述したデータセキュリティの進捗に伴ない、データディスカバリーが課題となってきました。そのため、データカタログ(Dataplexの導入)とメタデータ管理の強化(dbt-osmosisの導入)を行ないました。この内容の詳細については、Findy Toolsに寄稿した記事Data Engineering Study #22で発表した以下の資料を参照してください。

特にdbt-osmosisによる効果は大きく、カラムのdescriptionがほぼ入っていない状況(1割未満)からデータカタログが機能できる状況(5~8割)までカラムのdescriptionに持っていけたのは大きかったです。メタデータの拡充(メタデータの伝播)を自動化できた点も大きく、メタデータの所在をSSoTにしつつ、カバレッジを上げる環境を作ることができました。dbt-osmosisの導入 / 運用にあたり、不足していた機能もあったので、多数Pull Requestを送って取り入れていただきました。

また、メタデータはデータカタログだけでなく、データマネジメント全体を支えてくれる大きな武器にできているなと思います。単純なメタデータ整備に留まらず、アクティブメタデータに代表されるようなメタデータ管理の体制や活用への発展に繋げることができています。詳細については、datatech-jpで発表した以下の資料を参照してください。

大きく進捗を果たすことができたため、今後の優先度は一定下げつつ(優先度1.5)、よりデータ利用者に分かりやすいdescriptionをどう書くかやデータレイク側のメタデータを開発チームとどのように連携するか(Data Contractの導入検討)、TableauなどBIツール側へのメタデータ / データカタログの提供などより高度な項目について考えていきたいと思います(レベル感4)。

優先度が高かったにも関わらずあまり進まなかった項目

大きく進捗した項目があった一方、想定通りには進んでいない項目もありました。一番顕著な項目はデータアーキテクチャです(前回優先度が4.5であるにも関わらず、今回のレベル感が2。前回のレベル感は1.5)。これは主に著者である吉田がサボっていたからなのですが、入社してしばらく時間が経過すると頭の中に社内全体のデータアーキテクチャが入ってしまい、部内のメンバーも同じレベルの解像度を持っているため、データアーキテクチャを描き切らなくてもあまり課題になることがなかった、という背景がありました。

しかし、広告データの取り込み、CRMの導入(data activationを含む)、新BI(Tableau)の導入などに伴ない、データアーキテクチャは前回より複雑になっており、部内以外のメンバー(法務 / コーポレートIT / セキュリティチーム)とデータに関するディスカッションをするときに毎回口頭で説明する必要があるなど課題となってきています。また、チームに新規に所属したメンバーのキャッチアップコストが高くなってしまっているという課題にもなっています。

全ての項目を詳細に描き切るのは非常に骨が折れるため、今後は大まかな図から徐々に整える形でデータアーキテクチャを整備していく予定です(優先度2)。

まとめ

このエントリでは10Xで定期的に行なっているデータマネジメント成熟度アセスメントについて紹介しました。成熟度アセスメントの実施は初回こそ工数はかかりますが、一定型ができてくるとやりやすくなり、データマネジメン全体の見通しもよくなるため、データに対して課題がある人は是非試してみてください。

*1:これは結構大変で、SREに負けないくらいterraformを書いた一年でした