Gaudiy Tech Blog

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

dbt Semantic LayerとSteepで実現するデータ民主化

はじめまして、Gaudiyでアナリティクスエンジニア(副業)をしているkuwakenです。

Gaudiyでは現在、データの民主化を進めていますが、その過程でSQLを書く手間や負担、新しいBizメンバーのオンボーディングなどの面で、課題が生じていました。

今回は、その課題を解決するために導入した、dbt Semantic LayerSteepという2つのデータ系プロダクトに関するお話をします。

1. dbt Semantic Layerの導入背景

Gaudiyでは、Gaudiy Fanlinkというファンコミュニティプラットフォームを開発・提供しており、ビジネスサイドのメンバーもデータの抽出や分析を自ら行える環境づくりに取り組んできました。

その「データの民主化」を推進する過程で、以下のような課題に直面していました。

  1. SQLの手間と負担: チームメンバーはSQLを書くスキルを持っているものの、データ自体のドメイン知識を定着させるのに苦労しており、ちょっとしたクエリの修正や新規作成には時間がかかりがちでした。また、これをレビューしてもらうのは心理的な負担も伴っていました。

  2. Bizメンバーのサポート: 新しく入ったビジネスサイドのメンバーが、自分たちで迅速にデータを集計・分析できる環境が必要でしたが、現状の仕組みでは、データの抽出や分析に専門知識が必要で、自立してデータを扱うことが難しくなっていました。

これらの課題を解決するため、私たちはセマンティックレイヤーの導入を検討開始しました。

詳細は割愛しますが、この分野は老舗ではLookerがあったり、最近ではファーストパーティのこのような情報もあったり、話題を耳にすることが多いです。

Gaudiyでは、BigQuery + dbt Cloudという構成であったため、dbt Semantic Layerを導入することにしました。

2. Steep導入の決定理由

次に、BIツールについてです。そもそも私が「Steep」というBIツールをはじめて知ったのは、確かこちらの記事だったと記憶しています。

本プロジェクトの比較検討フェーズにおいて他のBIも検討しましたが、Steep導入を決定した理由は、以下の4つです。

  1. モダンなUIと使いやすい操作性
  2. スマホアプリによる場所を選ばないデータ分析
  3. dbt Semantic Layerとの連携によるスムーズなデータ連携
  4. リーズナブルな価格

ただ、新しいものであるがゆえに、導入フェーズではいくつかの課題にも直面しました。

  • セットアップ時に、dbt Cloudのus1リージョンを選択できなかった
  • 値が存在しない日にもグラフが表示され、誤った傾向を示してしまう
  • dbt側のMetricsでfilterを指定すると、Steepが発行するSQLに構文エラーが発生してしまうことがある(原因調査中)etc...

しかし、このようなバグレポートに対し、早いものでは即日対応してもらえたりするところは、やはり新興ツールならではのメリットだと思いました。一緒に製品を良くしていきたいというスタンスでいます。

3. dbt Semantic Layerに関するナレッジ

基本的にdbt Labsが提唱しているベストプラクティスにしたがって構築しましたが、実際にやってみてポイントだなと思った点をいくつかご紹介します。

3-1. ディレクトリ構造

dbtでセマンティックレイヤーを構築するには、semantic_models(measure, dimension)とmetricsをYAMLファイル内で定義する必要があります。

semantic_modelsとmetricsは同じYAMLファイルに書いても良いですし(通常のschema.yml内に記述することも可能)、metricsだけ別ファイルに切り出すのも良いらしく、その分け方も部署やエンティティなどさまざまなようです。

Gaudiyでは現在/models/semantic_models, /models/metricsの直下に格納しています。

models
├── (通常のモデルディレクトリ)
...
├── metrics
│   ├── met_hoge.yml
│   ├── ...
│   └── met_fuga.yml
└── semantic_models
    ├── metricflow_time_spine.sql
    ├── sem_piyo.yml
    ├── ...
    └── sem_hogera.yml

ただ、これでは数が増えてきた際に困りそうなので、さらにディレクトリを掘ろうかと考えているところです。

3-2. dbt Cloud CLI

本記事を執筆している時点(2024年5月)では、dbt Cloud IDEで出来ないことがあります。

  • ◯ Semantic Modelsなどのプレビュー、コンパイル、リネージ閲覧
  • × dbt-metricflowのコマンドを実行する
  • × 開発環境でセマンティックレイヤーを構築する

とくに3番目は困りもので、本番環境にデプロイしてからでないと検証ができないということになります。
そこでローカルでdbt Cloud CLIを使用して開発を行っています。

$ dbt compile
$ dbt sl query --metrics <metric_name> --group-by <dimension_name>

のようにコマンドを打つことで、開発中でもデータ取得が可能です。

3-3. 時間のdimensionはdatetime型で揃える(BigQueryユーザーのみ)

BigQueryでは、時間に関するdimensionを定義する際はdatetime型に揃えておく必要があります(URL)。もし異なるデータ型であれば、semantic_models内でexprでキャストしておけば良いです。

3-4. entitiesを理解する

entitiesが一番苦戦しました。最初は「そもそもこれは何なのか、別になくても良いのでは?」くらいに思っていましたが、とんでもありません。これがないと結合ができないのです。

たとえば以下のようにfct_ordersdim_customerという2つのモデルを、dim_customer_skというサロゲートキーで結合できるようにし、Steep上で「特定のカスタマーの注文合計を見たい」というニーズに応えたい場合、Gaudiyではこのように記述します。

# models/semantic_models/sem_fct_orders.yml

semantic_models:
  - name: fct_orders
    label: fct_orders
    description: 注文に関するファクトテーブル
    model: ref('fct_orders')
    defaults:
      agg_time_dimension: order_date

    # --- entities ---
    entities:
      - name: fct_orders
        type: primary
        expr: fct_orders_sk
      - name: dim_customer # カラム名は変わる可能性があるので、別名にして抽象化するようにしています
        type: foreign # 外部キーであることを宣言
        expr: dim_customer_sk # 実際のカラム名
    (以下略)

    # --- measures ---
    measures:
      - name: order_total
        label: order_total
        description: 注文合計
        agg: sum
        expr: amount
    (以下略)
# models/semantic_models/sem_dim_customer.yml

semantic_models:
  - name: dim_customer
    label: dim_customer
    description: 顧客に関するdimensionテーブル
    model: ref('dim_customer')

    # --- entities ---
    entities:
      - name: dim_customer # sem_fct_ordersの方のentitiesと名前を揃えることで、結合スタンバイOKな状態になります
        type: primary
        expr: dim_customer_sk

    # --- dimensions ---
    dimensions:
      - name: customer_id
        label: customer_id
        description: 顧客ID
        expr: id
        type: categorical
    (以下略)
# models/metrics/met_fct_orders.yml
metrics:
  - name: order_total
    label: order_total
    description: 注文合計
    type: simple
    type_params:
      measure:
        name: order_total
        fill_nulls_with: 0 # NULLを0埋めできる

これをSteepで表示すると、横軸にorder_date、縦軸にorder_totalのシンプルな時系列グラフが表示され、customer_idでフィルターをかけることができるようになります。ちなみにdaily, weekly, monthlyなどの粒度の切り替えはSteep上で行えますので、別途モデルを作る必要はありません。

4. 今後やりたいこと

今後やっていきたいこととしては、3点あります。

  1. 各種モデルの拡充:
    今回のプロジェクトではSemantic ModelsやMetricsの他に、その前段のベースとなるfactモデルやdimensionモデルのリファクタリング・新規追加を行いました。これらをさらに推し進め、より多くのディメンション・指標を扱えるようにします。

  2. ダッシュボードの整備:
    Steepに関して、現状はMetricsページ(探索用)のみ社内公開していますが、順次ダッシュボードを作成していこうと考えています。その際はTeams機能を使用して組織単位などでページを分けることになると想定しています。

  3. Semantic ModelsとMetricsファイルの効率的な管理方法の模索:
    前述の通り、ディレクトリ構造についてはまだまだ改善の余地があると思っています。複数ドメインをまたいでも適切に管理できるよう、整備していきたいと考えています。
    また、Semantic Models/Metricsの記述〜Steep側の更新反映までのプロセスを半自動化させたいとも考えており、新たなOSSライブラリが生えてこないかアンテナを張りつつ、なければ内製で用意することも視野に入れています。

5. さいごに

今回取り上げたdbt Semantic LayerとSteepは、まだ開発途上の段階であり、本番環境での利用事例も限られています。状況的には、dbt Core v0.x時代のようにも感じられます。

(私がdbtをはじめて触ったのは2021年の夏頃でしたが、ユーザーコミュニティ(dbt Tokyo)はまだなく、セットアップの方法すらよく分からずハマり、随分と時間を溶かしました。dbt-osmosisのような便利なライブラリもなかったので、ほとんどのYAMLファイルを人力で書いていました。)

しかし、これらのツールはデータユーザーの利便性向上に繋がる可能性を秘めています。Gaudiyでは『New Standard』という行動指針に基づき、既存の価値観や事例を徹底的に分析し次の時代を見すえた新しい技術や方法論に挑戦しています。今回ご紹介した取り組みも、その一例です。

現在Gaudiyではこうした取り組みを一緒に推進してくれるデータアナリスト(アナリティクスエンジニア)、データエンジニアを募集しています。ご興味のある方はぜひ下記の採用情報をご覧ください。

herp.careers

herp.careers

site.gaudiy.com

UnityからBackendエンジニアへの転生マネジメント術

こんにちは!GaudiyでBackendエンジニアをしているtakaです!

今回は、UnityエンジニアとしてCasual Gameチームで活躍されているkazuyaさんが、私が所属しているフィーチャーチームに異動して、1ヶ月半ほどBackend領域を学ぶための武者修行をしたお話です。

Backendの知識はまったくなかったkazuyaさんが、独り立ちするまでにどのようなサポートをしたのか、そのコツを含めてご紹介します!

1. UnityからBackendに転生することになった背景

kazuyaさんの所属するCasual Gameチームは、Unityでゲームをつくり、Web上で動作するゲームを開発するチームです。

当時、Unityに強いエンジニアは数名いるものの、BackendやFrontendを絡めた開発をできる人が1人しかいなかったので、機能開発でやれる幅が狭まったり、アジリティが上がりづらいという課題がありました。

そんななか、ある日、Casual Gameチームのリーダー haseyan と僕が雑談をしてた時のこと。

Casual Gameチームの課題感を聞いたなかで「UnityエンジニアがどこかのチームでBackend領域に挑戦すれば、Casual Gameチームの底上げになって、今以上に良いサービスを提供できそうだよね」という結論に至り、検討し始めることになりました。

それからUnityエンジニアであるkazuyaさんに相談した結果、本人も乗り気だったので、最終的には

haseyan「takaさんのチームでいける?」
僕「いけるよ!」

というコミュニケーションでアサインが決まりました笑

チーム間での調整は多少ありましたが、挑戦に対して積極的にサポートするのがGaudiyらしいかなと思います。

2. Gaudiyの開発体制とスタイル

僕が所属するチームは、Gaudiy Fanlinkというファンコミュニティのプラットフォームサービスを開発する、Feature Teamの一つです。

<開発組織の体制図>

Feature Teamは機能開発を行い、ユーザーに価値を提供することを目的としたチームです。

フルサイクルエンジニアリングをベースとし、設計・開発・運用・QAなどのサービスライフサイクルを1つの開発チームがすべて担当するため、幅広く開発できることが重要と考えています。

3. Backendの立ち上がりサポート

kazuyaさんは、つよつよUnityエンジニアなのですが、Backendについては技術スタック自体触ったことがない状態だったので、システム構成の説明とペアプロから重点的に始めました。その後、徐々に1人でタスクをこなしていただくように進めていきました。

3-1. Backendのシステム構成の説明

まずはGaudiyで利用しているアーキテクチャと技術スタックについて説明しました。

アーキテクチャの中で、特にgRPCやmicroサービスについての経験がなかったので、厚めに Why? という部分を説明することを意識しました。

3-2. ペアプロ

オンボーディングは、基本的に時間をかけて、齟齬が起きないようにペアで時間をとって進めました。

まずは簡単なタスクを切り出して、PRを出すまでVsCodeのLiveShareを利用してペアプロをしていました。ペアプロをするなかで、Backend開発を行う上で必要なprortobufの書き方やGoの開発におけるポイントの指導を行いました。

4. チーム開発に入る上で意識したこと

オンボーディングを一通り実施した後は、タスクを切り出して徐々に慣れていただくようにしました。この「タスクを切り出す」という部分で特に気をつけていたことを紹介したいと思います。

4-1. 成果を出しやすいタスクから渡す

まずタスクを進めていただくなかで、成果を出しやすいところから渡すという部分を意識していました。

成果の出づらいまたは勉強用のタスクはモチベーションが上がりづらく、チームにとっても本人にとってもよくないので、下記のような流れでチーム内の信頼構築ができるようなサポートを意識してました。

業務の中で成果を出していただく
→Slackでやっていただいたことをチームメンバーに共有
→何をしているのか認知してもらう

4-2. 似たようなタスクで反復を促す

また似たようなタスクを反復することで、Goを書くことに慣れていただくよう、タスクの割り振りをしました。

チームでは新規機能の開発が多かったので、CRUDを中心にドメイン理解をしながら慣れていただきつつ、チームに貢献いただいたと思います。

4-3. Blockerになりづらいタスクで幅を広げる

それから慣れてきた頃に、幅を広げるためのタスクをつくっていきました。タスク管理を行う上では、フロー効率で動いているチーム内でBlockerになりづらい、優先度が低いタスクを洗い出して割り振りを行いました。

その上で優先度の低いタスクの仕様など、意思決定のみ優先度を上げて、開発への貢献と挑戦を両立させるように意識しました。

そうすることでチームのタスク消化としてもかなり貢献いただき、スケジュールとして余裕があったわけではありませんでしたが、お互いに気持ちよく開発を進めることができたと思っています。

5. ぶつかった問題と乗り越え方

順調に開発を進められたように書いていましたが、別領域からの挑戦をしていただくなかで、躓いた部分もありました。

一例を出すと、テストコードの考え方や書き方についてです。CRUDを作っていただく分には問題ありませんでしたが、途中からビジネスロジックを組む際に、テストケースで何を網羅すればいいのか?現実に近いケースを実現するためのデータとは何なのか?などに詰まっていることに気づきました。

上記の問題がわかったのは、ペアプロで進めていたからです。kazuyaさんと会話すると、「そもそもUnityではあまりテストコードを書かずに、Debuggerを使うようにしていた」と聞いたので、テストを書く目的、特に保守性について伝えなければならないと考えました。

その問題がわかった後は、テストについての考え方がわかる『テスト駆動開発』を読むことをおすすめしました。

shop.ohmsha.co.jp

ただ内容が濃いのとページ数も多いので、簡潔にまとめられた記事をシェアしたり、私が認識している考え方を伝えたりしました。kazuyaさんは早速週末に読んでくれたようで、次の週からテストコードのペアプロがスムーズに進みました。

6. 成果(Before / After)

転生前のkazuyaさんは、Backendの技術スタック自体触ったことがなく、開発以前に、自力でキャッチアップするのも難しい状態でした。

そこから約1ヶ月半チーム帯同して、最終的には、反復したタスクについては雑な依頼の仕方でも仕上げていただけるようになりました。チーム内で開発リソースで困る場面もありましたが、kazuyaさんに頼らせていただいた部分もあり、最終的にスケジュールを組む際にも余裕が出たと思います。

kazuyaさんからも一言いただいたので紹介します!

普段ゲーム開発をしていますが、ノリで手を挙げてバックエンド開発を経験しました。アサイン前はUnity外の社内コードに触れたことがなく、他チームが何をどう開発しているのか見えていない状態でしたが、会社のプロダクトへの理解が深まったのはもちろんのこと、最終的にはサポートを受けながらBackendのContextを理解して開発をすることができたので、Goを書くのが毎日楽しみでした。Gaudiyでは別領域にもチャレンジできる環境・雰囲気があるのがとてもよかったです。

7. まとめ

UnityエンジニアからBackend領域に挑戦した内容を振り返りながら紹介させていただきましたが、いかがでしょうか?プロジェクトを進める上でタスクの割り振りについての考え方など参考になれば嬉しいです!

Gaudiyでは手を挙げれば、大変ですがやりたいことに挑戦できる環境なので、メンバーの挑戦をサポートできるように、またチームの開発生産性を最大化しつつ良いものを作っていきたいと思います!

もし興味を持たれたらお気軽にお声がけください!

site.gaudiy.com

site.gaudiy.com

ファンコミュニティの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