Gaudiy Tech Blog

Gaudiyの技術、開発組織、カルチャーについてお伝えするブログです

ファンコミュニティのUGCを効率的に届けるためiALSベースの協調フィルタリング推薦システムを作った話

はじめまして。GaudiyでMLエンジニアをしているMomijiと申します。主に推薦システムの開発を担当しています。

今年4月から、Gaudiyが開発・提供するプロダクト「Gaudiy Fanlink」に協調フィルタリングベースの推薦機能を追加したので、本記事ではそのロジックとシステムアーキテクチャについて書いてみたいと思います。

1. 「Gaudiy Fanlink」における推薦

Gaudiy Fanlinkは、IPファンが集う、SNS型のコミュニティプラットフォームです。そこにおける「推薦」の役割は、ユーザーとコンテンツのマッチングを促進し、コミュニティ内の活動総量を増加させることにあります。マッチングを促進するためには、まずユーザーの嗜好にあったコンテンツを提示し、より多くのコンテンツにアクセスしてもらうことが重要です。

推薦システムを導入する以前は、閲覧者が "能動的に" 自分の好きな投稿作品を探す必要があり、ユーザーとコンテンツのマッチングが効率的にデリバリーできてない状況でした。実際に、熱量の高いコアな制作者が集うIPコミュニティにおいても、「制作物を投稿 → 多くの人に閲覧される → リアクションで盛り上がる」というサイクルをいかに築けるか、が目下の課題でした。

これに対して、閲覧者が "受動的に" 好みの投稿に出会うことができれば、投稿の閲覧数やリアクション数の底上げにつながると考え、推薦システムを導入することにしました。

推薦には、そのロジックをCollaborative FilteringContent Based Filteringとで大別する考え方があります。Collaborative Filteringでは、嗜好に関するユーザ行動をスコアリングし、推薦を設計します。一方のContent Based Filteringでは、コンテンツの表現を生成し、ドメイン情報を組み込んだ推薦を設計します。

Gaudiy Fanlinkは単一の汎用プラットフォームではなく、複数のIPに特化したプラットフォームです。そのため、高度なドメイン情報のインプットを必要とするContent Based Filteringでは、IP毎の最適化において考慮すべき項目が多く、推薦システムの導入スピードが遅くなってしまう懸念がありました。

一方、Collaborative Filteringでは、アイテムへの嗜好とみなすユーザ行動を各コミュニティで考えて設計することができます。そこで初手としては、Collaborative Filteringによる推薦システムを構築することにしました。

2. 協調フィルタリングによるパーソナライズ推薦

Collaborative Filteringのアルゴリズムとして、今回はiALS(Implicit Alternating Least Squares)を採用しました。iALSは行列分解(Matrix Factorization)のアルゴリズムの一種です。

前提は省きますが、Funk-SVD ⇨ ALS ⇨ iALSという流れがあり、ユーザー数とアイテム数が多い状況でも並列計算による高速化ができる点、そしてユーザーがアイテムに対して嗜好を明示しない(Implicit Feedbackしかない)状況に対応している点が、重要な特徴です。

2-1. 学習

ユーザーのアイテムに対する嗜好をスコアリングして評価値とする。アイテムに対するクリック、いいね、リプライなどから評価値を決定します。

\displaystyle{
r_{u, i} = f(click, like, reply)
}

次にこの評価値をユーザーxアイテムとなる評価値行列として定義し、これをユーザーの行列Wとアイテムの行列Hに分解することを考えます。行列分解は損失関数を最小化するようなWとHを求めることで行われます。iALSにもいくつかの損失関数の定義がありますが、ここでは下記の定義をしています。

第一項は、分解したWとHが元の評価値行列を近似できているかどうかを評価しており、観測されているユーザーとアイテムのペア要素について、WとHから再現した評価値行列の差を最小化しています。第二項は、評価値が得られていない要素が大きな値を取らないように、ペナルティを課しています。第三項は、汎化のための正則化項です。

 \displaystyle
\hat{r}_{u, i} := \langle\mathbf{w}_u,\mathbf{h}_i\rangle,\space\space\space\space \mathbf{W}\in \mathbb{R}^{U\times d} \mathbf{H}\in \mathbb{R}^{I\times d}

 \displaystyle
\underset{\mathbf{W} \space \in \space \mathbb{R}^{U\times d}, \space \mathbf{H} \space \in \space \mathbb{R}^{I\times d}}{argmin} \space \underset{(u,\space i) \space \in \space \mathbf{R}}{\sum} \left( r_{u, i} - \hat{r}_{u, i} \right)^2 + \alpha \underset{u \space \in \space U}{\sum} \underset{i \space \in \space I}{\sum} \hat{r_{u,i}}^2
+ \lambda \left(  \underset{u \space \in \space U}{\sum} \| \mathbf{w}_u \|^2 + \underset{i \space \in \space I}{\sum} \| \mathbf{h}_{i} \|^2    \right)

この損失関数をWとHについて交互に最適化します。分離可能であるため、各ステップのWとHは各ユーザー,アイテムについて並列で更新することができます。例えばユーザーの行列Wでユーザーuのベクトル\displaystyle{\mathbf{w} _ u}について最適化する場合、Hを固定した状態で\displaystyle{\mathbf{w} _ u}による偏微分が0となるときの\displaystyle{\mathbf{w} _ u}を求めることになります。

 \displaystyle
\frac{\partial}{\partial \mathbf{w}_u}
\left(
\underset{i \space \in \space \mathbf{R}_u}{\sum} \left( r_{u, i} - \mathbf{w}_u^T\mathbf{h}_i \right)^2 + \alpha \underset{i \space \in \space I}{\sum} (\mathbf{w}_u^T\mathbf{h}_i)^2
+ \lambda \left(   \| \mathbf{w}_u \|^2 + \underset{i \space \in \space I}{\sum} \| \mathbf{h}_{i} \|^2    \right)
\right) = 0

 \displaystyle
\frac{\partial}{\partial \mathbf{w}_u}
\left(
\underset{i \space \in \space \mathbf{R}_u}{\sum} \left( r_{u, i}^2 - 2r_{u, i}\mathbf{w}_u^T\mathbf{h}_i + (\mathbf{w}_u^T\mathbf{h}_i)^2 \right) + \alpha \underset{i \space \in \space I}{\sum} (\mathbf{w}_u^T\mathbf{h}_i)^2
+ \lambda \left(   \| \mathbf{w}_u \|^2 + \underset{i \space \in \space I}{\sum} \| \mathbf{h}_{i} \|^2    \right)
\right) = 0

 \displaystyle
\underset{i \space \in \space \mathbf{R}_u}{\sum} \left(-2r_{u, i}\mathbf{h}_i + 2\mathbf{h}_i\mathbf{h}_i^T\mathbf{w}_u \right) + 2\alpha \underset{i \space \in \space I}{\sum} \mathbf{h}_i\mathbf{h}_i^T\mathbf{w}_u
+ 2\lambda \mathbf{w}_u  
 = 0

 \displaystyle
\underset{i \space \in \space \mathbf{R}_u}{\sum} \mathbf{h}_i\mathbf{h}_i^T\mathbf{w}_u + \alpha \underset{i \space \in \space I}{\sum} \mathbf{h}_i\mathbf{h}_i^T\mathbf{w}_u
+ \lambda \mathbf{w}_u  
 = \underset{i \space \in \space \mathbf{R}_u}{\sum} r_{u, i}\mathbf{h}_i

 \displaystyle
\mathbf{w}_u  
 = \left( \underset{i \space \in \space \mathbf{R}_u}{\sum} \mathbf{h}_i\mathbf{h}_i^T+ \alpha \underset{i \space \in \space I}{\sum} \mathbf{h}_i\mathbf{h}_i^T+ \lambda \mathbf{I} \right)^{-1} \underset{i \space \in \space \mathbf{R}_u}{\sum} r_{u, i}\mathbf{h}_i

アイテムについても同様に以下のように記述できます。

 \displaystyle
\mathbf{h}_i  
 = \left( \underset{u \space \in \space \mathbf{R}_i}{\sum} \mathbf{w}_u\mathbf{w}_u^T+ \alpha \underset{u \space \in \space U}{\sum} \mathbf{w}_u\mathbf{w}_u^T+ \lambda \mathbf{I} \right)^{-1} \underset{u \space \in \space \mathbf{R}_i}{\sum} r_{u, i}\mathbf{w}_u

なお第二項の部分は、実は全ユーザーまたは全アイテムに対して共通となります。よって並列計算の前に一回だけ計算しておけばよい値となり、元論文では”Gramian trick”と呼ばれている、計算量の削減が可能な部分です。

2-2. バッチ推論

学習バッチで得られた行列WとHから、\displaystyle{\hat{r} _ {u, i}}をそのまま計算してユーザーごとに推薦候補を得ることができます。最後にソートロジックなどを適用してキャッシュしサービングします。

2-3. リアルタイム推論

ユーザーの評価値が更新されたら、ユーザベクトル\displaystyle{\mathbf{w} _ u}をリアルタイムで更新することができます。なお更新と書きましたが新規のユーザーに対しても、評価値があれば推論可能です。

 \displaystyle
\underset{\mathbf{w}_{new} \space \in \space \mathbb{R}^d}{argmin}  \space \underset{i \space \in \space \mathbf{R}}{\sum} \left( r_{i} - \langle{\mathbf{w}}_{new} ,\mathbf{h}_i\rangle \right)^2 + \alpha  \underset{i \space \in \space I}{\sum} \langle{\mathbf{w}}_{new} ,\mathbf{h}_i\rangle^2
+ \lambda \underset{u \space \in \space U}{\sum} \| \mathbf{w}_{new} \|^2

基本的には学習バッチと定式は変わらないので詳細は割愛しますが、計算負荷はそこそこ高いので、スループットやレイテンシに課題を感じたら、適宜高速化したり、近似したりして計算します。

有名どころとしてはコレスキー分解や共役勾配法などがあります。簡単なシミュレーションを示しますが、通常の逆行列計算と比較してコレスキー分解はより早く、共役勾配法は適切なイテレーションで打ち切ることでロングテールを改善しているようです。ただし打ち切るイテレーションについては、推論精度を確認して決定することが望ましいです。双方ともScipyの実装があります。

3. システムアーキテクチャ

今回の推薦システムの構築は、GaudiyがCloud RunからGKEへ移行していたタイミングで実施しました。GKE移行についてはこちらの記事を参照いただければと思います。

推薦システムも流れに合わせ、バッチとサービスを両方GKEで構築しています。なおバッチはCloud Composerで構築しているので、かなり簡単に構築できました。(SREチームには大変お世話になりました。)

細かい点ですが、リアルタイム推論は近似解を利用したとしても一定の計算リソースを消費しますし、アイテム行列やGramian trickの行列を保持しておく必要があるため、メモリも十分に確保しておく必要があります。そこでML用に専用のノードプールを用意し、ユーザー、アイテムスパイクで他のマイクロサービスのリソースを圧迫しないようにしています。

4. これから

今回のリリースは複数の機能が同時リリースであったこともあり、推薦機能の導入による具体的な効果までは分析できていません。現在ABテストを実装しているので、結果については追って報告できればと思います。

また推薦候補でのパーソナライズは、ユーザー数が増加するといずれ難しくなると考えています。よってクラスタリングやグラフベースの候補生成も視野に入れています。

さらに今回のリリースではビジネスユースケースでのソートのみが実装されていますが、推薦候補に対するRerankingも重要であると考えています。こちらはユーザー行動と事業KPIとの紐付きをモデリングするところから始めています。(推論の大部分をバッチに寄せつつも、サービスレイヤが存在する上のアーキテクチャも、Rerankingを考慮しての構成です。)

最後に、Gaudiy Fanlinkはファンコミュニティであるという性質上、ドメインが多種多様で、その一つひとつがとてもディープです。私たちはこれに対して、マルチモーダルデータの活用により対応しようとしています。

現在Embeddingモデルの基礎研究やマルチモーダルLLMに関する研究開発を絶賛推進中です。こうした取り組みに興味のある方がいれば、ぜひお話しさせてください。Gaudiyでは一緒に取り組んでいく仲間も募集しています。

site.gaudiy.com

site.gaudiy.com

OpenTelemetry Collector導入の実践編とその後

はじめまして。Gaudiyでエンジニアをしているあんどう(@Andoobomber)です。

以前、「OpenTelemetry Collector導入のPoCと今後に向けて」という記事を弊エンジニアの sato(@yusukesatoo06)より公開しました。簡単に記事を要約すると、

  1. OpenTelemetry及びOpenTelemetry Collectorの説明
  2. 実際にPoCを作ってみる
  3. 実導入を試みたがOpenTelemetry Collectorのホスティングに悩み、今後の課題として保留となった

といった内容でした。

あれから1年経ち、GaudiyではOpenTelemetry Collectorを本番環境に組み込み、OpenTelemetryの仕様に準拠して計装し、データの分析や監視を行っています。この記事では、前回からの進捗を紹介すると共にOpenTelemetryの導入方法を書きたいと思います。

1. 前回のPoCから変わったこと

1-1. インフラ: Cloud Run → Google Kubernetes Engine(GKE)

前回上がった導入の課題として、「CollectorのホスティングにCloud Runを使用する案が上がったが、Cloud Runの特性上、常時立ち上げておく必要があるCollectorとの相性が悪く断念した」といった課題がありました。(当時はCloud Run SidecarがPreviewでした)

これをどう解決したかというと、Gaudiyのアプリケーション(Gaudiy Fanlink)のインフラ環境をKubernetes(k8s)環境に乗せ替えることで解決しました。

※ただし、OpenTelemetry Collectorの問題を解消するためにk8sに移行した訳ではなく、複合的な要因でk8sに移行しました。詳細については「Kubernetes初学者が担当したGKE移行プロセスの全貌」に書かれているので是非みてください!

1-2. 監視: GCP → Datadog

また、MonitoringツールもGCPの監視サービス(Operations Suite, Cloud Trace/Metrics/Logging)を使っていたのですが、Datadogに乗り換えました。Datadogはとても高機能でGCPやGithubなど多種多様なアプリケーションへのIntegrationがあることや、直感的に操作できるUXが使いやすく乗り換えを決意しました。

元々、全てのアプリケーションはOpenTelemetryの仕様に準拠していたので、Datadogへの移行は、GCP OpenTelemetry Exporterを使ってGCPへExportしていたのを、Collector経由でDatadogにExportするだけで済みました。これがOpenTelemetryの強みであり、予め準拠しておいて本当によかったなと思います。

前回のPoCと現在の構成

2. OpenTelemetry Collectorの導入

2-1. Kubernetesリソースの定義

GaudiyではKubernetesのリソース設定にKustomize × Helmを使っています。以下のKustomize YAMLをArgoCDがGKEに適用しています。

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

metadata:
  name: opentelemetry-collector

namespace: opentelemetry-system

helmCharts:
- name: opentelemetry-collector
  repo: https://open-telemetry.github.io/opentelemetry-helm-charts
  releaseName: opentelemetry-collector
    ...

patches:
    - patch: |-
      - op: replace
        path: /spec/template/spec/containers/0/image
        value: ${CUSTOM_OTEL_COLLECTOR_PATH} # カスタムしたOTel Collectorを使用
  - path: ./configmap.yaml # ConfigMapにPatchを充てる
    target:
      version: v1
      kind: ConfigMap
        ~省略~

2-2. Custom OTel Collectorの用意

OpenTelemetry CollectorではDistributionとしてCoreContrib が用意されており、Coreはextensions/connectors/receivers/processors/exporters のベース部分が備わっており、Contribは加えてOTel開発者以外のサードパーティ等が開発した機能(Component)が備わっています。

しかし、OTel Collectorアンチパターンの記事にあるようにCoreもContribも本番環境で使うのは推奨されておらず、アプリケーションに必要なComponentを適時選んで独自でビルドして使うことが勧められています。Gaudiyも独自にビルドしたImageをCUSTOM_OTEL_COLLECTOR_PATH にPushして使っています。

以下はCollectorで使用するComponentのManifestになります。

dist:
  module: github.com/gaudiy/**
  name: otelcol
  description: "Gaudiy OpenTelemetry Collector distribution"
  otelcol_version: "v0.95.0"
  output_path: out
  version: "v0.95.0"

receivers:
  - gomod: go.opentelemetry.io/collector/receiver/otlpreceiver v0.95.0
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/receiver/hostmetricsreceiver v0.95.0
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/receiver/prometheusreceiver v0.95.0

connector:
  - gomod: go.opentelemetry.io/collector/connector/forwardconnector v0.95.0
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/connector/datadogconnector v0.95.0
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/connector/routingconnector v0.95.0
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/connector/servicegraphconnector v0.95.0
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/connector/spanmetricsconnector v0.95.0

processors:
  - gomod: go.opentelemetry.io/collector/processor/batchprocessor v0.95.0
  - gomod: go.opentelemetry.io/collector/processor/memorylimiterprocessor v0.95.0
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/processor/attributesprocessor v0.95.0
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/processor/k8sattributesprocessor v0.95.0
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/processor/probabilisticsamplerprocessor v0.95.0
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor v0.95.0
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/processor/transformprocessor v0.95.0

exporters:
  - gomod: go.opentelemetry.io/collector/exporter/debugexporter v0.95.0
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter v0.95.0

extensions:
  - gomod: go.opentelemetry.io/collector/extension/zpagesextension v0.95.0
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckextension v0.95.0

↑のmanifestをOpenTelemetry Collector Builder(ocb)を使ってビルドしています。

ビルドの手順については公式ドキュメントに書いてあるので、是非そちらを見てみてください。

2-3. CollectorのConfigを設定

ConfigMapにCollectorの設定値を書いています。以前の記事で紹介したreceiverやprocessorなどのフィールドを設定しています。

(一部のパラメータのみ載せています)

apiVersion: v1
kind: ConfigMap
metadata:
  name: opentelemetry-collector-**
data:
  relay: |
    receivers:
      otlp:
        protocols:
          grpc:
            endpoint: 0.0.0.0:4317
          http:
            endpoint: 0.0.0.0:4318

        ~省略~

        processors:
      transform:
        error_mode: ignore
        log_statements:
          - context: scope
            statements:
              # DataDog cannot pick up the span name as resource on
              # Service Entry Spans. We append it here. This might have a
              # side effect to overwrite an application that is instrumented with
              # DataDog SDK. However, as long as OpenTelemetry SDK is used to
              # instrument, this transformation will help.
              - set(attributes["resource.name"], name)
          # - context: log
          #   statements:
          #     - set(attributes["env"], attributes["deployment.environment"])

        ~省略~

        exporters:
        datadog:
          api:
            key: ${env:DD_API_KEY}
            fail_on_invalid_key: true
          host_metadata:
            enabled: true
          traces:
            span_name_as_resource_name: true
            compute_stats_by_span_kind: true
            peer_service_aggregation: true
          metrics:
            resource_attributes_as_tags: true
            histograms:
              mode: counters
              send_aggregation_metrics: true
          logs:
            dump_payloads: true

        extensions:
      health_check: ~
      memory_ballast: ~
      zpages: ~

        service:
      telemetry:
        metrics:
          address: 0.0.0.0:8888

      extensions:
        - health_check
        - memory_ballast
        - zpages

      pipelines:
        traces:
          receivers:
            - otlp
          processors:
            - resourcedetection
            - k8sattributes
            - attributes/trace
            - transform
            - batch
          exporters:
            - datadog

        metrics:
                ~省略~

        logs:
                ~省略~

変わった設定は特にしていないのですが、特筆すべきところは以下の3つです。

2-3-1. exportersにdatadogを設定

DatadogのAPI KeyをSecretで定義しておいて、ENVに渡して設定しています。

そして、.service.pipelines.traces.exportersにもdatadogを使うことを指定します。tracesだけでなくmetrics/logsでも同様に指定しています。

exporters:
  datadog:
    api:
      key: ${env:DD_API_KEY}
      fail_on_invalid_key: true

service:
  pipelines:
    traces:
      exporters:
        - datadog

2-3-2. processorsでDatadog用にAttributeを設定する

DatadogではSpanにresource.nameの属性を設定する必要があるのですが、OTelの仕様ではname という属性で扱われています。解決方法として、processorsでDatadogのspecに合わせて加工しています。

先ほどのCustom OTel Collectorの所でtransformprocessorというcomponentを導入しており、ConfigMapにてtransformの処理を書いています。

processors:
    transform:
      error_mode: ignore
      log_statements:
        - context: scope
          statements:
            - set(attributes["resource.name"], name)

2-3-3. Loggingは社内独自ライブラリを使用

GoのLogging SDKは公式でもまだIn developmentのステータスですが、Javaなどは既にSDKが提供されている関係でProtocol Buffersの定義は存在したので、それをベースに社内でzapのadaptorを実装し先行して使っています。

この記事の公開時点では公式にもGoのSDK実装が徐々に入りつつあり、APIが安定した段階で移行する予定です。

以上の設定で、OTel Collectorを立てています。

2-4. アプリケーションにてExport先を設定する

実際のアプリケーションコードは複雑なので割愛しますが、OpenTelemetryのデモ用のコードサンプルを例にすると以下のようなコードになります。

// Collectorのアドレスを設定
// http://opentelemetry-collector.opentelemetry-system.svc.cluster.local:4317
otelAgentAddr, ok := os.LookupEnv("OTEL_EXPORTER_OTLP_ENDPOINT")
if !ok {
    otelAgentAddr = "0.0.0.0:4317"
}

// Metricsの設定
metricExp, err := otlpmetricgrpc.New(
    ctx,
    otlpmetricgrpc.WithInsecure(),
    otlpmetricgrpc.WithEndpoint(otelAgentAddr))
handleErr(err, "failed to create the collector metric exporter")

meterProvider := sdkmetric.NewMeterProvider(
    sdkmetric.WithResource(res),
    sdkmetric.WithReader(
        sdkmetric.NewPeriodicReader(
            metricExp,
            sdkmetric.WithInterval(2*time.Second),
        ),
    ),
)
otel.SetMeterProvider(meterProvider)

// Traceの設定
traceClient := otlptracegrpc.NewClient(
    otlptracegrpc.WithInsecure(),
    otlptracegrpc.WithEndpoint(otelAgentAddr),
    otlptracegrpc.WithDialOption(grpc.WithBlock()))
traceExp, err := otlptrace.New(ctx, traceClient)
handleErr(err, "failed to create the collector trace exporter")

bsp := sdktrace.NewBatchSpanProcessor(traceExp)
tracerProvider := sdktrace.NewTracerProvider(
    sdktrace.WithSampler(sdktrace.AlwaysSample()),
    sdktrace.WithResource(res),
    sdktrace.WithSpanProcessor(bsp),
)

// set global propagator to tracecontext (the default is no-op).
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))
otel.SetTracerProvider(tracerProvider)

OTel Trace/MetricsのGo SDKにExport先としてOTel Collectorのアドレスを渡すだけで設定は完了です。

TraceやMetricsはサンプルコードのようにして、好きな箇所で計装することができます。

// Metrics
meter := otel.Meter("demo-server-meter")
serverAttribute := attribute.String("server-attribute", "foo")
commonLabels := []attribute.KeyValue{serverAttribute}
requestCount, _ := meter.Int64Counter(
    "demo_server/request_counts",
    metric.WithDescription("The number of requests received"),
)
requestCount.Add(ctx, 1, metric.WithAttributes(commonLabels...))

// Trace
span := trace.SpanFromContext(ctx)
bag := baggage.FromContext(ctx)
var baggageAttributes []attribute.KeyValue
baggageAttributes = append(baggageAttributes, serverAttribute)
for _, member := range bag.Members() {
    baggageAttributes = append(baggageAttributes, attribute.String("baggage key:"+member.Key(), member.Value()))
}
span.SetAttributes(baggageAttributes...)

3. (おまけ)opentelemetry-go-instrumentationを試す

OTelではAuto Instrumentation(自動計装)という、組み込むとテレメトリーデータを自動計装してくれるライブラリを出しています。自動計装は言語毎にメカニズムが異なり、各言語毎にライブラリが作られています。

Go言語はまだ開発段階ではありますが opentelemetry-go-instrumentation が出ており、今回は開発環境で試験的に導入してみたのでその詳細について書こうと思います。

※ただし、まだ開発段階なのでLinuxディストリビューションやGoのバージョンによっては動作しない場合があります。

3-1. OpenTelemetry Operatorの利用

OpenTelemetry Operatorは、k8sクラスター内でOpenTelemetryコンポーネントを管理するoperatorです。今回は opentelemetry-go-instrumentation をSidecarで立てるためにoperatorを使います。

まずはhelmを使いリソースの定義をします。

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

metadata:
  name: opentelemetry-operator

namespace: opentelemetry-operator-system

helmCharts:
- name: opentelemetry-operator
  repo: https://open-telemetry.github.io/opentelemetry-helm-charts
  releaseName: opentelemetry-operator
  version: 0.x
  namespace: opentelemetry-operator-system
  valuesInline:
    pdb:
      create: true
    manager:
      featureGates: "+operator.autoinstrumentation.go"
    kubeRBACProxy:
      image:
        tag: v0.14.3
  includeCRDs: true

featureGatesとして"operator.autoinstrumentation.go" を有効にしておきます。

3-2. アプリケーション側の設定

アプリケーション側にはSidecarをinjectするアノテーションを付けます。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
  labels:
    app: my-app
spec:
  selector:
    matchLabels:
      app: my-app
  replicas: 1
  template:
    metadata:
      labels:
        app: my-app
      annotations:
        instrumentation.opentelemetry.io/inject-go: "true"
        instrumentation.opentelemetry.io/otel-go-auto-target-exe: "/path/to/container/executable"

また、OTel OperatorがアプリケーションにSidecarをInjectできるようInstrumentationのリソースを作ります。

apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
  name: post-service
  namespace: post-service
spec:
  exporter:
    endpoint: http://opentelemetry-collector.opentelemetry-system.svc.cluster.local:4317
  go:
    image: ${AUTO_INSTRUMENTATION_IMAGE_PATH}
    resourceRequirements:
      limits:
        cpu: 500m
        memory: 2Gi
      requests:
        cpu: 500m
        memory: 1Gi
    env:
      - name: OTEL_EXPORTER_OTLP_ENDPOINT
        value: http://opentelemetry-collector.opentelemetry-system.svc.cluster.local:4317

これによりGoにてAuto Instrumentが可能になりました。

4. 今後の方針

以上により本番環境でOTel Collector経由でDatadogにExportすることができました。ただ、まだまだ課題もあるので今後の方針としていくつか挙げたいと思います。

  1. 可用性: Collectorの構成をAgent modeGateway mode両方使って可用性高い構成にしたいと思っているのですが、費用面と現状のアプリケーションの規模を見て意図的に対応していません。将来的には変えていきたいと思っています。(OTel Collectorアンチパターンの記事にも記載されています)
  2. 機能面: 今の所、最低限のSpanしか登録していない & Attributeも上手く使いこなせていないので、目的に合わせた計装の最適化を行っていきたいと思っています。

5. まとめ

今回はGCPからDatadogへの移行、OpenTelemetry Collectorの導入、そしてopentelemetry-go-instrumentationのテスト及びOpenTelemetry Operatorの利用といった一連のステップを通じて、効率的かつ効果的なテレメトリーデータの収集と分析方法を探求しました。この記事を通して、これからOpenTelemetryを導入しようとしている人の参考になると嬉しいです。

またGaudiyとしては、前回の記事が出てから1年経ち、紆余曲折ありましたがOpenTelemetryを実運用に取り込むことができてObservabilityの向上にむけてかなり前進したのではないかと思っています。

始めた当初、DevメンバーがTrace/Metricsを見て能動的にボトルネックの改善やアプリケーション監視に役立ててくれると良いなと思っていたのですが、その兆候が社内でも見られ始めているので、今回の取り組みは本当にやって良かったと思っています。

ボトルネック調査をして下さったSlackの投稿

Observablityの向上に取り組んでいる人や、OpenTelemetryに関心がある人がいたら、ぜひカジュアルにお話ししましょう。

site.gaudiy.com

Gaudiyではエンジニアを積極採用中なので、興味ある人はぜひこちらもご覧ください。

site.gaudiy.com

GoとCobraを用いた新規マイクロサービス用ボイラープレートの自動生成CLIツールでコスト削減した話

こんにちは。ファンと共に時代を進める、Web3スタートアップ Gaudiy でソフトウェアエンジニアをしている ryio1010です。

私は弊社が提供するファンコミュニティプラットフォーム「Gaudiy Fanlink」の開発において、フィーチャーチームの一員として、主にバックエンド開発を担当しています。

バックエンドのアーキテクチャにはマイクロサービスを採用していますが、会社のフェーズ的に試行錯誤の段階であることや、それに伴うチーム体制の変更がよく起きていることもあり、新しいマイクロサービスの立ち上げも頻繁に行われています。

私自身もこれまでの業務で2〜3つの新しいマイクロサービスを立ち上げる経験をしてきました。

今回は、これらのマイクロサービスの立ち上げと運用の経験から、特に立ち上げフェーズにフォーカスし、改善を行った事例をご紹介したいと思います。

1. マイクロサービスの概要とGaudiyのシステム構成について

本記事をお読みの皆さんにとって、マイクロサービスの概念はすでにご存知の内容かもしれません。しかし、後続の内容をより深く理解していただくために、まずはマイクロサービスの基本的な概念と、Gaudiyにおけるシステム構成の現状を簡潔にご説明します。

1-1. マイクロサービスとは?

マイクロサービスは、大規模なアプリケーションを小規模かつ独立したサービス群として構築する設計手法です。このアーキテクチャスタイルでは、各マイクロサービスが特定の機能やビジネス要件に特化し、独立して開発、デプロイ、運用されます。

例えば、Gaudiyでは、ユーザー関連の処理を担う「user-service」、投稿関連の処理を担う「post-service」などが存在します。

マイクロサービスはそれぞれ独立しているため、Go、Kotlin、TypeScriptなど、異なるプログラミング言語での開発も可能です。

実際にGaudiyでは、これらの言語を使用したマイクロサービスを運用しています。

主なメリットは以下の通りです:

  • 開発とデプロイメントの迅速化: 各マイクロサービスは独立して開発・デプロイできるため、迅速な更新が可能。
  • 耐障害性の向上: サービス間が疎結合になるので一つのサービスに障害が発生しても、他のサービスには影響が少ない。
  • 複数技術スタックの利用: 異なるサービスで異なる技術スタックを採用できる。

一方でデメリットも存在するため、考慮に入れておく必要があります。

  • 通信の複雑さ: サービス間の通信が多く、複雑になる傾向がある。
  • データ管理の難しさ: 分散したサービス間でのデータ一貫性の維持が課題。
  • 運用コストの増加: 個別サービスの監視・管理には追加の労力とリソースが必要。

多くのメリットがあるマイクロサービスですが、上記のようなデメリットもあります。 この記事では、マイクロサービスの運用におけるコスト増加の部分、特に新規マイクロサービスの立ち上げコスト削減に焦点を当てています。

1-2. Gaudiyのシステム構成

現在のシステム構成は下記のようになっています。

Fanlinkシステム構成

フロントエンドではReact(Next.js)とTypeScriptを用い、BFFとしてApollo ServerとTypeScriptを採用しています。バックエンドではGoとKotlin・Pythonを使用したマイクロサービス群を構築しています。 これらの技術スタックを基に、ブロックチェーン技術やAI・LLM(Large Language Models)技術を取り入れながら、当社のプロダクト「Fanlink」の開発を進めています。

バックエンドに焦点を当てると、ホスティング環境としてGoogle CloudのGKE(Google Kubernetes Engine)を使用しています。現在、20近くのマイクロサービスをGKE上で運用しています。

2. 既存の新規構築手順と課題感

2-1. これまでのマイクロサービス構築手順

まず、弊社で新規マイクロサービスを立ち上げるために必要な要素と、従来の構築方法についてご説明します。

新規マイクロサービスの立ち上げには、大きく分けて以下の3種類が必要です。

  • マイクロサービス用のgRPCサーバーのコードベース(Go)
  • GCP環境でホストするためのGCPリソースを作成するTerraformファイル群
  • 各環境へのデプロイなどを担うCI/CDやDBマイグレーションを行うGitHub Actions用のyamlファイル群
2-1-1. gRPCサーバー用のコードベース(go)

まずマイクロサービスのサーバー本体となるコードベースについては、一部はKotlinを採用していますが、新規サービスは基本的にGoを使用しています。

Gaudiyにはecho-serviceという、いわゆる新規マイクロサービスの元になるコードベースがあります。

このテンプレートには、ログクライアントの設定、DBクライアントの設定、サーバーの起動など、マイクロサービスに必要な基本機能がGoで書かれています。

これまではこのecho-serviceをコピーする形で新しいマイクロサービスのコードベースを作成していました。コピーしていく中で、コードベースの中の名前を新しく作成するマイクロサービスのドメインに合わせるように手作業で修正していくという形でした。

手作業で修正する必要があり、その量もそれなりになるため、細心の注意を払っていたとしてもヒューマンエラーが発生したり、そもそもecho-serviceからの新規サービス作成が初めてな人も多いので必要以上に時間がかかってしまうことも少なくない状況でした。

2-1-2. terraformファイル

GCP環境でマイクロサービスを動作させるためには、Service AccountやSpanner InstanceなどのGCPリソースが必要です。

これらのインフラ管理はTerraformを用いて行っており、既存のファイルに新規マイクロサービスのリソースを追加する必要があります。

この作業に関しては手順書があるため、他の作業に比べると比較的スムーズに作業できていました。

2-1-3. cicd用yamlファイル

各マイクロサービスには下記のためのgithub actions用のyamlファイルがあります。

  • 各環境へdeploy用
  • SpannerDBのマイグレーション用
  • 各PRの確認用(ユニットテスト実行・lint実行など)

これらに関しては手順書もなく、これまで新規構築したことのあるメンバーに逐一どのファイルが必要かを確認して手動で作成している状況でした。

ファイルの追加漏れなどヒューマンエラーも発生しやすい状況となっており、実際私もDBマイグレーション用のファイル追加がもれており、DBのマイグレーションが実行されず追加し直すといった手戻りが発生してしまった経験がありました。

2-2. 課題感

マイクロサービスは上記でお伝えした利点があり採用しているのですが、会社のフェーズ的にも試行錯誤をしていく中で、実績値で平均すると2-3ヶ月に1回くらいのペースで新しいマイクロサービスを立ち上げていました。

私は新規マイクロサービスの構築に多く関わる機会があり、初めて構築を行う中で以下の課題があると個人的に感じていました。

  • 初期構築手順書はあるものの、十分に更新されておらず有識者に確認する必要があり開発の手が止まってしまう
  • 手順書には書かれていない暗黙知があり、チームメンバーによっては暗黙知を持っておらず新規構築したもののなぜがうまく動かないといったことが起こりうる
  • 基本的に手作業でテンプレートとなるディレクトリをコピーする形でコードベースを作るのでタイポなどのヒューマンエラーが発生し、作業が手戻りしてしまう
  • そもそも手順が多く手作業が大変

上記の状況の中で、実際に新規マイクロサービス基盤の構築が完了しコア機能の開発に入るまでに1-2人日くらい工数がかかっているため、少なからず開発スピードを鈍化させる原因になっていることは明らかでした。

また基本的にどんなマイクロサービスを立ち上げるとしても、そのマイクロサービスが扱うドメインの名称が違うだけで新規立ち上げの作業自体は定型的なものであるため、十分に自動化できうるものでした。

3. 具体的なアプローチ方法と効果

3-1. アプローチ方法

先にご紹介した新規マイクロサービス構築に必要な3種類の作業のうち、Terraformに関しては手順書通りの追記で問題なく、大きく工数を要していなかったため、GoサーバーのコードベースとGitHub Actions用のyaml群の生成を自動化の対象としました。

詳しい実装については割愛しますが、おおまかに下記のような処理を実装している「gauctl」という名前のCLIを作成しています。

  1. GitHubのリポジトリからecho-serviceをクローン(tarファイル)
  2. 取得したtarを解凍
  3. 変更が必要な箇所を新規マイクロサービスのドメイン名で置換
  4. GitHub Actions用のyamlのテンプレートを基に、新規マイクロサービス用のyamlを生成(Go言語のテンプレート機能を利用)
  5. 適切なディレクトリに生成ファイルを出力

3-2. 利用した技術について

利用した技術スタックは以下の通りです。

  • Go言語
  • Cobra(GoのCLI アプリケーションフレームワーク)

Go言語はCLIツールの作成に適しているだけでなく、Gaudiyではバックエンドに広く採用されており、多くのエンジニアが読み書きできるため、将来的なメンテナンスの観点からも採用することにしました。

またCobraはGo製のCLIフレームワークで、容易にコマンド生成やフラグ処理などを直感的なAPIで実装できるため、開発の効率やメンテナンスが容易になるという点から採用しました。

ここではより具体的な実装イメージを持っていただくためにも、yamlの生成で利用したGo言語のテンプレート機能とCLIアプリケーションフレームワークとして利用したCobraについて簡単な使い方をご紹介できればと思います。

3-2-1. Go言語のテンプレート

今回yamlの生成にはGo言語のテンプレート機能を利用しました。

この機能を使うことで動的なデータ(今回の場合は新規マイクロサービスのドメイン名)を静的テキストファイルに組み込むことができ、”text/template”パッケージから利用できます。

ディレクトリ構成は下記の感じです。(一部抜粋)

├── transform
│   └── templates
│       └── sample1.yaml
│       └── sample2.yaml
│   └── embed.go // yamlファイルをbyte配列で読み込む処理を記載
│   └── transform.go // yamlの中身を変換する処理

yamlファイルの中身で変換したい部分には「{{ . }}」と記載します。

(この部分がtemplateの機能によって変換されます。)

// transform/templates/sample1.yaml
name: Check PR for {{ . }}-service

on:
  push:
    branches:
      - "main"
    paths:
      - "{{ . }}-service/**"
  pull_request:
    paths:
      - "{{ . }}-service/**"

まずembed.goで”embed”パッケージを利用して”templates内のファイルをbyte配列で読み込んでいます。そしてそれをTransform関数の引数として渡しています。

// transform/transform.go

// contentにyamlファイルのbyte配列を、replacedStrには変換したいドメイン名が入る
func Transform(content []byte, replacedStr string) ([]byte, error) {
  // New関数でテンプレートを作成して、Parse関数で文字列を解析し、テンプレート定義に追加する
	t, err := template.New("yaml").Parse(string(content))
	if err != nil {
		return nil, fmt.Errorf("failed to parse: %w", err)
	}

	var buf bytes.Buffer
    // Execute関数で、テンプレートの内容を変更し、bufに格納する
	if err = t.Execute(&buf, replacedStr); err != nil {
		return nil, fmt.Errorf("failed to execute: %w", err)
	}

	return buf.Bytes(), nil
}

上記のTransformが実行されると、sample1.yamlは下記のようになり、それを指定したディレクトリに出力しています。

(replacedStrにfooを指定した場合)

// transform/templates/sample1.yaml
name: Check PR for foo-service

on:
  push:
    branches:
      - "main"
    paths:
      - "foo-service/**"
  pull_request:
    paths:
      - "foo-service/**"

このようにtemplate機能を使うことで動的なコンテンツを簡単に作成することができます。

3-2-2. Cobra

CLIのディレクトリ構成は下記の感じです。(一部抜粋)

(cobraのコマンドをインストールして自動生成もできます。)

$ go install github.com/spf13/cobra-cli@latest
$ cobra-cli init
├── cmd
│   └── create.go
│   └── root.go
├── main.go

サブコマンドもコマンドでファイル生成できます。

(↓のコマンドを実行すると、cmd下にcreate.goが生成されます。)

$ cobra-cli add create

Cobraでのサブコマンドの実装はCommandというStructのフィールドに値を定義していく形で作成します。

基本的なフィールドの説明は下記になります。

  • Use: コマンド名とその使用方法(引数など)を定義
  • Short**:** コマンドの短い説明
  • Args**:** コマンドに渡された引数を検証する関数を定義
  • RunE: エラーを返却できる、コマンドが実行された際に呼び出される関数の定義

上記以外にも色々と設定できる項目はあるものの、基本的にRunEにサブコマンドが実行された時に実行したい処理を書くことでCLIを実装することができるようになっています。

// cmd/create.go
package cmd

import (
	"fmt"

	"github.com/spf13/cobra"
)

var (
	createExample = `
* Create new micro-service from template
	`

	createCmd = &cobra.Command{
		Use:     "create [domain_name arg]",
		Short:   "Create new micro-service from template",
		Example: createExample,
		Args: func(cmd *cobra.Command, args []string) error {
			// 引数に関する処理(引数の検証など)をここに記述できる
			if len(args) != 1 {
				return fmt.Errorf("requires 1 arg(s), received %d", len(args))
			}
			return nil
		},
		RunE: func(cmd *cobra.Command, args []string) error {
			// 以下にコマンドが実行された時に実行したい具体的な処理を記述

			return nil
		},
	}
)

func init() {
	rootCmd.AddCommand(createCmd)
}

CobraはKubernetesやDockerなどでも採用されているとのことで筆者も初めて使用してみましたが、直感的で分かりやすく、素早く軽量のCLIを作成するツールとして優れていると感じました。

3-3. 実際の効果

CLIをリリースしてから、既に4つの新規マイクロサービスが立ち上がっており、全てこのCLIを使用いただいています。

以前は1-2日かかっていた環境構築作業が、定型作業を自動化することができたため、現在ではおよそ半日で完了するようになりました。

手順は以下のように変わりました。 特に時間を要していたecho-serviceのコピーとドメイン名の置換作業、およびGitHub Actions用のyamlファイルの作成作業が、コマンド一つで完了できるようになりました。

3-3-1. これまで
  1. echo-serviceをコピー
  2. echo-serviceのドメイン部分を新規マイクロサービスの名称に置換し、Goサーバーのコードベースを作成(ここに多くの工数がかかっていた)
  3. Terraformファイルに必要なリソースを追加
  4. GitHub Actions用のyamlファイル群を追加(ここも手順書がなく、工数がかかっていた)
  5. PRを出す
  6. レビューし、mainブランチへマージ
  7. CI/CDにより新規マイクロサービスの環境が構築される
3-3-2. CLI導入後
  1. CLIを実行(マイクロサービスのコードベースとGitHub Actions用のファイル群を生成)
    • 実際のコマンド例: foo-serviceを作りたい場合
      • gauctl create foo
  2. Terraformファイルに必要なリソースを追加
  3. PRを出す
  4. レビュー、mainブランチへマージ
  5. CI/CDにより新規マイクロサービスの環境が構築される

主にヒューマンエラーや経験不足(暗黙知だった部分)が原因で発生していた手戻りや、定型的な作業を自動化することで、工数を大幅に削減できたのかなと思います。

4. おまけ(CloudRun→Kubernetes移行について)

少しおまけ的な話にはなりますが、当時、マイクロサービスのホスティングはCloud Runを利用していました。そのため、初回リリース時にはCloud Runで動作するコードベースを自動生成するCLIを作成していました。

その後、全社的にCloud RunからKubernetesへのホスティング基盤の移行が行われました。この移行は、横断的なチームであるenableチームによって進められました。 それに伴い、CLIもKubernetesで使用できるように機能拡張が必要となりました。

詳しい話は別の機会に書かせていただければと思いますが、Cloud Runとは違いKubernetesで動作させるためには、deployment.yamlやservice.yamlなどの設定ファイルが必要になります。 したがってKubernetesに対応するために、これらの設定ファイルを生成するコマンドを追加し、必要なファイルの自動生成が可能になりました。

Kubernetesへの移行経緯や移行方法の詳細は、以下の記事で詳しく書かれています。興味のある方はぜひご覧いただけるとうれしいです! 

techblog.gaudiy.com

5. おわりに

この記事では、マイクロサービスを運用する中でも特に新規立ち上げフェーズにフォーカスし、改善を行った事例についてお伝えさせていただきました。

改善を行う際のコストと効果を比較して改善するべきかを熟考することは重要ですが、定型的な作業の自動化は、ヒューマンエラーによる手戻りの防止や開発スピードの改善に寄与できると思うので、積極的に改善して良い部分になるかなと思います。

また、定型的な作業は自分の所属するチーム内だけではなく、他のチームも同様の課題を抱えがちです。そのため、開発組織全体の開発スピード向上に寄与しやすい部分なのではないかと個人的には感じています。

Gaudiyでは、課題に直面した際、本人が主体的に積極的に解決に取り組む文化があります。これは一人で行うのではなく、周囲を巻き込みながら(アドバイスをもらったり、改善方針のすり合わせなどで相談に乗ってもらえる)課題解決を進めていけるので気軽に改善活動に取り組めると感じています。

今後もフィーチャーチームに在籍しながら、横断的に解決できる課題を見つけ、開発スピードの向上に寄与していければと思います〜!

今回の記事が皆さんの参考になれば幸いです。

Gaudiyでは、一緒に働くエンジニアを積極的に募集しています。当社の技術や開発スタイルに興味を持った方は、選考前のカジュアル面談も可能ですので、ぜひ採用ページからお気軽にご応募ください!

site.gaudiy.com

Kubernetes初学者が担当したGKE移行プロセスの全貌

はじめまして。Gaudiyでエンジニアをしているあんどう(@Andoobomber)です。

クラウドネイティブ全盛の世の波に乗り、この度 Gaudiy では Cloud Run から Google Kubernetes Engine (GKE) への移行を行いました。

この記事では、その移行プロセスの全体像を共有し、得られた教訓と今後の展望を探ってみたいと思います。

1. Before After: 移行の概観

1-1. Before

Before

以前までの構成は、

  • マイクロサービスの実行環境として Cloud Run を使用
  • 監視ツールにはGCPの Cloud Monitoring, Cloud Logging, Cloud Trace を使用
  • Pub/Sub→マイクロサービス間に API Gateway を経由
  • アプリケーションのデプロイは Github Actions が行う

といった感じでした。

1-2. After

現在の構成は、

  • マイクロサービスの実行環境としてGKEを採用。1つの Cluster 上で全てのサービスを動かしています。
  • 監視ツールにはDatadogを採用し、OpenTelemetry Collector経由でテレメトリーデータを流しています。
  • Pub/Sub→マイクロサービス間にESPv2を経由。
  • アプリケーションのデプロイはArgo CDが行い、Github ActionsArtifact Registryに push した Image を Argo CD Image Updater が Watch し、Cluster にデプロイしています。

といった感じに変わりました。

2. なぜGKE環境に移行したのか

移行に至った経緯は様々あるのですが、主に↓の3つが理由です。

  • クラウドネイティブな技術選択をしたい
    • CNCF Projectsをはじめとする、コンテナ技術の最新トレンドを採択したい
    • Kubernetes Controller で自由に機能拡張できる
  • Ops 起因で重要な目的があった
    • これが大きな理由だったのですが、機密情報に当たるので申し訳ないですが詳細は伏せさせていただきます
  • 機械学習関連でマシンリソースを選びたかった
    • Gaudiyではレコメンドエンジンなど、AIにも注力しており、通常サーバーとは異なるマシンリソースが必要になった

3. 移行のプロセス

普通なら移行計画を立ててKubernetes(k8s)に移行していくところですが、僕はk8s未経験であり前提知識も全くなかったので、k8sを学ぶことから始めていきました。

3-1. Kubernetesを学ぶ (1週間: 2023/10/01~)

Kubernetes完全ガイド 第2版を読んでサンプルアプリを作ってました。これが無かったら移行完了までのスケジュールは大きく変わっていたと思うくらい良書でした。

この時に、DeploymentやServiceなどのk8sオブジェクトを理解するとともに、ライフサイクルやエコシステムなども学んでいきました。

3-2. Dev on GKE環境作成 (2-3週間)

FanlinkのアーキテクチャはFE → Load Balancer → BFF → BE(micro services)という構成になっており、今回は Load Balancer 以降を GKE に全く同じ環境を作成して、FEの向き先を変えて移行することにしました。

GKE環境を作る際に様々な技術を使用しましたが、1つずつ書いていくと書ききれないので、以下にやったことを抜粋して記載します。詳細は割愛しますが、後に別記事としていくつか公開する予定です。

  • Cluster の作成
  • 各マイクロサービスのKubernetesオブジェクト定義
    • サービス: 27個、cronjob: 8個ほどありました。
  • External Secrets の使用
    • Secret Managerを参照するため使用。
  • OpenTelemetry Collector の使用
    • テレメトリーデータをOpenTelemetry Collectorに集約させて、まとめて Datadog にexportしています。
  • Gateway API の使用
    • つい先日GAとなったKubernetes Gateway API を使って、Load Balancerを管理しています。
  • ESPv2 の使用
    • REST から gRPC へのトランスコーディングとして、Envoy ベースの ESPv2 を使用しています。
  • Argo CD の導入
    • k8s リソース管理及びCDとして、Argo CD を採択しました。
  • etc…

3-3. Staging on GKE環境作成 (2日)

基本的には環境変数の変更で、Devで作成したyamlをStagingも同じように作っていくだけなのでそこまで難しくはありませんでした。

この時、開発チームへGKE環境に触ってもらう・受け入れテストのお願いをアナウンスしました。

3-4. Private Clusterへの移行 (1-2週間)

今まではデフォルトの設定で Cluster を作成していましたが、Private Cluster にすることでコントロールプレーンとノードに対して外部アドレスを割り当てなくする(≒よりセキュアにする)ことができると知り、Stating/Prod 環境は Private Cluster で建てることにしました。

  • GCPリソースのTerraform
    • GKE Cluster
    • Network / Subnetwork
    • Firewall
    • etc…
  • Tailscale Operator 導入
    • Private Cluster の操作を Tailscale Network 経由で行うようにしました。
  • Private Clusterでの環境再作成
    • コントロールプレーンのパブリックエンドポイントアクセスを無効にして、よりセキュアな Clusterへと変更しました。

3-5. Prod on GKE環境作成 (2日)

こちらもStaging同様にyamlを複製し、Prod用の環境変数を割り当てる程度なのでそこまで難しくはありませんでした。

ただし、cron処理やoutbox patternが存在したので現行の環境に影響を与えないように気をつけながら作業を行っていきました。

3-6. 負荷試験・受け入れテスト (1週間)

Staging環境を作成した段階で、開発チームへのアナウンスや負荷試験をProd環境作成と並行して行っていきました。

最後の大詰めとして以下のような作業をコツコツと進めていきました。

  • バグの解消
    • 環境変数や外部サービスとの通信部分で特にバグが起きました…
  • Pod/Nodeリソース・スケール値の設定
    • Grafana k6で負荷試験をして、想定負荷に必要なリソースを計算しました。幸い、瞬間的に同時アクセスが大量に走るような複雑なサービスではないので、ある程度の負荷を余裕もって耐えれる位の設定値にしました。
    • ただ、それでも単一Podだと落ちた時が怖かったので複数Podをminimumとし、負荷試験に耐えられる位をmaxとして設定しました。

3-7. リリース(2023/12/04)

2023/12/04の深夜にCloud Run環境からGKE環境にSwitchしました。移行後から1週間くらいは異常が起きないかモニタリングを続けましたが、特段大きな問題はなくアプリケーションが稼働していたのでホッとしました。

4. 移行後の感想

4-1. 良かった点

  • おおよそ2ヶ月でk8s環境へ移行できた。
    • 経験者の知見もいただきながらですが、初学者ながらわりと順調に進めることができたと思います。
  • 最新のクラウドネイティブ技術への追従ができる。
    • CNCF Projectsに登録されているプロジェクトを見て活かせそうなツールがないかみるのが最近楽しいです。
  • リソースの調整
    • 機械学習系のマシンが使えるの嬉しい。
  • レスポンスタイム向上
    • 全体約200msほど
  • Datadogに移行したこと
    • 高機能でかなり使いやすいです。今後のSRE業務に拍車がかかりそうです。

4-2. 課題点

  • k8sの学習コストが高い
    • k8sへの完全移行は達成したものの、k8s APIやらeBPFなど学ぶことがいっぱい。まだまだこれからだなと感じています。
  • 運用コストも高い
    • 学習コストに近いですが、定期的なk8sのアップデートやCSP(クラウドサービスプロバイダ)の知識がないとアプリケーションを落としかねないので、運用は簡単とは言えなさそうです。
    • また、適切なリソース設定をしないと費用面でも無駄なコストがかかってしまいます。

5. 今後の展望

現在は最小構成のk8s環境で動いているので、様々なクラウドネイティブ技術を積極的に試していきたいと考えています。

  • Ciliumの導入
  • eBPFの調査
  • Flux (GitOps)
  • Kubernetes Custom Controller 作成
  • Config Connector

6. 結論

Kubernetes移行はかなり大変でしたが、Gaudiyのビジョンに向けたソリューションと技術的な野心を反映した、意義深い変革の一歩だったと思います。

クラウドネイティブ技術の進化に伴い、より良いサービスとソリューションを提供するために、今後もアップデートを続けていきたいと考えています。

この領域に知見のある方や、Gaudiyでのプロダクト開発に興味のある方がいたらぜひお話ししましょう!

site.gaudiy.com

site.gaudiy.com

Authlete を活用して OAuth 認可サーバの構築期間を短縮した

こんにちは、Gaudiyでソフトウェアエンジニアを担当しているsato(@yusukesatoo06)です。

弊社が提供するファンコミュニティプラットフォーム「Gaudiy Fanlink」において、外部サービスにAPI提供をする必要があったことから、外部連携について色々と調べて実装しました。

そこで今回は、調査からサーバ構築までのプロセスと、そこで得た学びや気づきを共有できればと思います。

1. OAuthとは

1-1. OAuthの概要

OAuthとは、Webやモバイルアプリケーションなどのクライアントアプリが、ユーザーに代わって他のウェブアプリがホストしているリソースにアクセスできるよう設計されたプロトコルです。(みなさんご存知かもしれないですが...)
参考) OAuth 2.0 とは何か、どのように役立つのか? - Auth0

OAuthを使用することで、ユーザーはクライアントアプリに対して、自身のID/パスワードを直接提供せず、他サービスとの連携を可能にします。そのため、複数のサービス間で連携を行うために非常に重要な技術となっています。

※OAuthが解決する課題については、LayerXさんの記事が参考になります。

1-2. OAuthのフロー

OAuthには認可フローがいくつか存在し、大きく以下の4つで構成されます。

  • 認可コード
  • インプリシット
  • リソースオーナー・パスワード・クレデンシャルズ
  • クライアント・クレデンシャルズ

それぞれのフローには、異なる用途やセキュリティ要件があるため、適切なフローを選択する必要があります。ここに関しては、以下の記事が詳しいです。

kb.authlete.com

僕たちは、上記記事でも推奨されている認可コード + PKCE(※)パターンを採用しました。

※ 認可コード横取り攻撃に対する対策
参考) Proof Key for Code Exchange (RFC 7636) - Authlete

2. OAuthが必要な背景

2-1. 外部サービス連携

今回、OAuthが必要になった背景として、外部サービスからGaudiyのAPIをコールしたいという要求がありました。また実装に関しても、1, 2ヶ月程度で行う必要があり、かなりタイトなスケジュール前提でのAPI連携となりました。

(※ ただしビジネス的な要件でAPI連携が後ろ倒しになったため、まだ実運用には至っていません)

API連携を行う手段はいくつかあります。

  1. APIキーでの連携
  2. 独自方式での連携
  3. OAuthでの連携 など

今回は工期の関係や、後述する少し特殊な環境下での連携だったため、最初は1か2を選択しようと考えていました。

しかし最終的には、3のOAuth形式を採用することとなりました。その主な理由は以下です。

  • クライアントサイドの実装も独自となり、エコシステムを利用できない
  • 今後、別のサービスと連携する際の拡張性が乏しい

2-2. 他の連携方式との比較

1. APIキーでの連携

まず最初に検討にあがったのがAPIキーでの連携方式でした。

こちらはIP制限等と併用することで、一定のセキュリティを担保することができるかつ工数のかからない方式となります。

ただし今回、Fanlink APIをコールするクライアントが、サーバサイドだけではなくクライアントアプリも含まれる前提でした。そのため、早々に本手段は選択できないことが決定しました。

2. 独自方式での連携

前述した通り、今回の連携環境が少し特殊な関係で、独自方式の連携を検討していました。

具体的に説明すると、Fanlinkと連携サービスが同一のIdPとOIDC連携をしており、同じIdPのアカウントで作られたサービス間を連携する環境でした。

そのため、IdPで発行されたID Tokenを流用することでサービス間の連携ができないかを検討していました。

一般的な方式ではないですが、工期を考えるとOAuthをゼロから作ることが難しいため、本方式を検討しました。またセキュリティの監査会社に相談した結果、セキュリティホールとなる部分もありませんでした。ただし前述の通り、クライアントサイドも独自実装となるため、本手段も選択しませんでした。

そのため、僕たちは腹を括って短期間でOAuthの仕組みを構築することにしました。

3. OAuthの提供

3-1. 提供方式

短期間でOAuthサーバを提供する必要があるため、まずはどういった方式で提供できるかを検討しました。

その中で提供方式は大きく3つ出てきました。

  • フルマネージド
    • サービスプロバイダー側で提供されるOAuthサービスを利用する方式
    • 例) Auth0
  • ハイブリッド
    • サービスプロバイダー側で提供されるAPI群を利用し、自社で認可サーバを構築し提供する方式
    • 例) Authlete
  • フルスクラッチ
    • ライブラリ等を用いて、自社で認可サーバを0から構築し提供する
    • 例) fosite, hydra など

それぞれ以下のPros/Consがありました。 (Gaudiyの開発環境に依存する理由も含まれます)

サービス Pros Cons
フルマネージド 導入までの期間が圧倒的に短い。フルマネージドでの提供のため運用工数がかからない。Auth0等であればグローバルでの実績があり、セキュリティ面の担保もされている Firebase Authをすでに導入しており、IdP機能が既存サービスと競合してしまう。
ハイブリッド OAuth/OIDCに特化したサービスで、既存のIdPと競合しない。フルスクラッチに比べ運用工数がかからない。コアな部分はAPIとして提供されており、セキュリティ監査等をクリアした機能を利用できる。 フルマネージドに比べ、一定の開発/運用工数がかかってしまう。
フルスクラッチ フルマネージド / ハイブリッドと比較し、ランニングコストがサーバ費用のみ。 開発/運用工数がかなりかかる。セキュリティ監査など周辺工数/コストもかなりかかる。実装に問題があった際に、会社として大きなリスクを抱える。

3-2. 今回の選定方式

今回は前述した3つの方式のうちハイブリッド型を選択し、サービスとしてはAuthleteを採用しました。

すでに導入しているFirebase Authと競合しない点、フルスクラッチに比べると開発工期を大きく圧縮できる点が理由となります。

また開発者向けのドキュメントが充実していることや、Goで利用するライブラリも用意されていた点も決め手となりました。

4. OAuthサーバの構築

4-1. Authleteについて

では実際に、OAuthサーバを構築した際の内容に移ろうと思います。

本来であればここで苦労話を記載したいのですが、Authleteを活用したこともあり、1 ~ 2ヶ月の工期に対して意外とすんなり構築できました笑

今回採用したAuthleteは、RFCに準拠するエンドポイントをAPIとして提供していて、利用者はクライアントからのリクエストをバイパスするのみでOAuthサーバを提供できるサービスとなっています。 (詳しくはサービス概要をご覧ください)

https://www.authlete.com/ja/developers/overview/

そのため、まずは今回必要なエンドポイント群を整理するところから始めました。

4-2. 必要なエンドポイント

今回のOAuthサーバの提供にあたり、外部向けには大きく以下のエンドポイントを用意しました。

  • 認可エンドポイント
  • トークンエンドポイント
  • リソースサーバエンドポイント

それぞれ簡単に説明していきます。

認可エンドポイント

認可エンドポイントは、連携するサービスの認証/認可処理を開始するためのエンドポイントです。一般的には連携サービスの認証画面でログイン処理を行い、OAuthの処理を開始した後に、指定したリダイレクトURLが認可コードを付与された状態で戻ってきます。

仕様についてはRFC6749 3.1に仕様が記述されており、わりと柔軟に実装することができます。

例えばパスについては、フラグメントを含めなければどのような値でも利用することができます。

詳しい仕様はAuthleteさんの記事が分かりやすいです。

トークンエンドポイント

トークンエンドポイントでは、認可エンドポイントで取得した認可コードを用いてリクエストに利用するトークンを発行します。

またリフレッシュトークンを利用してリクエストすることで、トークンのリフレッシュも行うことが可能です。

仕様についてはRFC6749 3.2に記述されています。

リソースサーバエンドポイント

リソースサーバエンドポイントは、実際に認可されたリソースにアクセスするためのエンドポイントで、提供機能に応じて用意するエンドポイントとなります。

トークンエンドポイントで発行したトークンを用いてアクセスを行い、スコープに準拠したリソースであればOAuth連携したサービスから情報取得することができます。

上記以外にも、内部で利用するためのエンドポイントをいくつか用意しておりますが、今回は割愛させていただきます。

4-3. システム構成

今回、OAuthサーバを構築した際のシステム構成は以下です。

OAuthはHTTP/1.1をベースにしたプロトコルである一方で、GaudiyのバックエンドエコシステムがgRPCベースの構成となっているため、以下の形で実現しました。

構成図の通りではありますが、ポイントは以下です。

  • API Gatewayを構築し、OAuthに関する外部通信は基本API Gatewayでハンドリングすることで、プロトコル準拠の通信を実現
  • 後続サービスとの通信に関してはgRPCベースで通信を行い、Gaudiyのエコシステムを流用する
  • Authleteとの通信は基本的にパススルー方式とし、クライアントからのリクエストをほぼ加工せず送信することで、レスポンスも可能な範囲でそのままクライアント側に返却する

また外部公開するURL仕様やバージョニングも悩む部分が多かったです。

大手サービスが提供しているエンドポイントを調査すると以下のような仕様でした。

  1. Google

     https://accounts.google.com/o/oauth2/vX/auth
    
  2. Facebook

     https://www.facebook.com/vXX.X/dialog/oauth
    
  3. GitHub

     https://github.com/login/oauth/authorize
    
  4. Twitter

     https://api.twitter.com/oauth/authorize
    

上記のうちGoogleの形式を参考に、僕たちは以下のような設計にしました。

https://<domain>/oauth/vX/<method or resource>
例) https://<domain>/oauth/v1/authorize

API仕様が変わるケースは少ないと思いますが、外部連携を行う前提に立つとクライアント側との調整/修正が必要なため、後方互換性を担保しつつ切り替え可能にしました。

5. 開発を通じて

5-1. 開発を通じた学び/気づき

今回、OAuthサーバというRFCに厳密に準拠すべきサーバを開発して、色々と学び/気付きがありました。

まずRFCの内容が意外と解釈の余地があり、何かしらの指針がないと解釈に差が生まれる恐れがあるなと感じました。そのためAuthleteという、開発するための指針があったのは非常に助かりました。その一方で、RFCに準拠した自前の認可サーバを作る選択肢を取っていたら、途方に暮れる工数がかかっていただろうなと思います。

またこのようなプロトコルを策定した過去の技術者達のすごさを改めて感じました。Webサービスを開発しているとRFCを読む機会は少ないですが、一度RFCに準拠するサービスを作ることは非常に学びが多いなと感じました。

個人/チームとしては、GitHubのやり取りの中でRFCについて触れるなど、開発者としての成長を感じた場面もありました笑

5-2. フルスクラッチとの比較

フルスクラッチで開発した場合を想定し比較すると、半分以下の工数で実装できたのではないかと思います。工数を圧縮できた大きなポイントは以下です。

  • 基本的にAuthleteの設定後、Authlete提供のAPIをコールするだけでよいため実装方針が明確
  • RFCに準拠するために必要となる知識が圧倒的に少ない(とはいえ開発者としては理解しておくべきですが)
  • 自分たちの開発範囲が少ないため、セキュリティ監査などの対象/工数も削減できる
  • シンプルなものであればすぐに構築出来るため、動くものを確認しながら試行錯誤ができる

5-3. 今後の対応

これまでAuthleteを活用したOAuthサーバの構築に触れてきましたが、冒頭でも説明した通り、残念ながら実運用にはまだ至っていません。

本来であればOAuthスコープの設計をAPI毎に行う必要があり、スコープ設計もかなり重たい内容となる想定です。ただし今回は実運用を行うエンドポイントがなくなってしまったため、スコープ設計は後ろ倒しとなりました。

OAuthサーバの実運用を行った際は、再度スコープ周りの知見を共有できればと思います。

6. まとめ

今回はOAuthサーバの実装について、その採用背景から構築方法まで紹介させていただきました。調査した内容や実際に感じたこと含めてまとめてみたので、これからOAuthサーバの実装を検討している人の参考になれば嬉しいです。

また今後の実運用に向けて、OAuthのスコープ設計周りに知見がある人がいたらぜひお話ししてみたいです。

Gaudiyに興味を持っていただいた方は、ぜひ以下のサイトも覗いてみてください!

site.gaudiy.com