Classi開発者ブログ

教育プラットフォーム「Classi」を開発・運営するClassi株式会社の開発者ブログです。

ライブラリのアップデートを自動化した仕組みの紹介

こんにちは!学習動画・Webテストの開発を行っています エンジニアの daichi (id:kudoa) です。 この記事では、最近チームで導入したライブラリアップデートを自動化した仕組みとその経緯について紹介します。

なぜ自動化しようと思ったか

サービスを開発するだけではなく、日々の運用も必要です。

その運用業務の1つとして、ライブラリのアップデートがあります。 これはサービスを運用する上では大切なことではありますが、日々ライブラリアップデートのPRをさばき続けるのも大変です。

その時間をできるだけ減らし、その分空いた時間をユーザーへの価値提供や将来の投資に充てるために、今回の自動化の仕組みを作成しました。

この辺りの話は以前勉強会でLTしたことがありますので、興味があればご覧ください。

作ったもの

前置きは長くなりましたが、凝ったものを作ったわけではありません。

作成したものはライブラリアップデートの PR を 自動マージする GitHub Acitons の workflow です。 既に稼働しており、今まで20件以上のライブラリが自動でマージされました。

導入したリポジトリはバックエンドアプリとフロントエンドアプリをモノレポで管理しています。 それぞれのアプリで自動マージする要件を変えていますが、やることは大体同じです。 プロダクションに影響がないライブラリに対して、CI が通れば github-actions[bot] で Approve してからマージするワークフローになっています。

このCIの要件はバックエンドアプリ と フロントエンドアプリで以下のように決めています。

  • バックエンドアプリの workflow
  • 対象:dependabot が作成する PR
  • 自動マージの要件:lintとテスト が通り development dependency group に属すること

バックエンドアプリの workflow のソースコード(一部抜粋)

name: Dependabot Automation
on:
  pull_request:
    paths:
      - 'Gemfile.lock'

permissions:
  pull-requests: write
  contents: write
  issues: write
  repository-projects: write
  packages: read

jobs:
  ci_and_automerge:
    if: ${{ github.actor == 'dependabot[bot]' }}
    runs-on: 'ubuntu-latest'
    timeout-minutes: 30
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      - name: Install Ruby and gems
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.1
          bundler-cache: true
      - name: Run lint
        run: bundle exec rubocop -P
      - name: Run tests
        run: bundle exec rspec
      - name: Dependabot metadata
        id: metadata
        uses: dependabot/fetch-metadata@v1.6.0
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
      - name: Approve & enable auto-merge for Dependabot PR
        if: |
          (steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor') &&
          steps.metadata.outputs.dependency-type == 'direct:development'
        run: |
          gh pr review --approve "$PR_URL"
          gh pr edit "$PR_URL" -t "(auto merged) $PR_TITLE"
          gh pr merge --auto --merge "$PR_URL"
        env:
          PR_URL: ${{ github.event.pull_request.html_url }}
          PR_TITLE: ${{ github.event.pull_request.title }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  • フロントエンドアプリの workflow
  • 対象:renovatebot が作成するPR
  • 自動マージの要件:main と PR のブランチで build 結果の差分を比較し差分がないこと

フロントエンドアプリの workflow のソースコード(一部抜粋)

name: Renovatebot Automation
on:
  pull_request:
    paths:
      - 'yarn.lock'
permissions:
  pull-requests: write
  contents: write
  issues: write
  repository-projects: write

jobs:
  compare-static-assets:
    timeout-minutes: 10
    if: ${{ github.actor == 'renovate[bot]' }}
    runs-on: ubuntu-latest
    steps:
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
      - name: Checkout current branch
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - name: Install dependencies
        run: yarn
      - name: Build on current branch
        run: |
          yarn build:production
          rm -rf public/fonts/
          rm -rf public/img/
      - name: Upload output file on current branch
        uses: actions/upload-artifact@v3
        with:
          name: ${{ github.sha }}-current
          path: public
      - name: Checkout main branch
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.base.sha }}
          fetch-depth: '0'
      - name: Reinstall dependencies
        run: yarn
      - name: Build on main branch
        run: |
          yarn build:production
          rm -rf public/fonts/
          rm -rf public/img/
      - name: Download current export file
        uses: actions/download-artifact@v3
        with:
          name: ${{ github.sha }}-current
          path: out-current
      - name: Compare build assets
        run: |
          diff -rq out-current public > result.txt || true
      - name: Comment results of diff
        uses: actions/github-script@v6
        with:
          github-token: ${{secrets.GITHUB_TOKEN}}
          script: |
            const fs = require('fs')
            const result = fs.readFileSync('result.txt', 'utf8')
            const commentBody = result ?
              `静的アセットのビルド結果に差分があります👀 <p>${result}</p>`
              : '静的アセットのビルド結果に差分はありません🎉'
            await github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: commentBody
            })
      - name: Approve & enable auto-merge for Renovatebot PR
        run: |
          diff -rq out-current public
          gh pr review --approve "$PR_URL"
          gh pr edit "$PR_URL" -t "(auto merged) $PR_TITLE"
          gh pr merge --auto --merge "$PR_URL"
        env:
          PR_URL: ${{ github.event.pull_request.html_url }}
          PR_TITLE: ${{ github.event.pull_request.title }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

補足 GitHub Actions のワークフローで PR の作成や Approve を行うためには、事前に Organization で許可をする必要があります。

GitHub Actions による pull request の作成または承認を回避する

フロントエンドアプリに 「development dependency group に属すること」という要件を入れていないのは、ライブラリがdevDependenies に属してもプロダクションに影響与える場合があるためです。 例えば babel や webpack などのビルドツールはバージョンが上がることで、ビルド結果が変わる可能性があります。

そのためプロダクションに影響がないのはビルド結果の差分を検知することだと判断しました。

作る際に詰まったところ

ここではこのワークフローを稼働させるまでに詰まったところについて共有します。

code owner が指定されていると自動マージができない

branch protection rules で code owner のレビューを必須にしている場合は、github-actions[bot] で Approve しても要件を満たすことができないため、マージできません。 また 2023年10月現在では bot を code owner に登録することもできません。

GitHub Apps can’t be used in CODEOWNERS – that’s not supported. https://github.com/orgs/community/discussions/23064

この問題は code owner を使わないことで解決しました。

code owner は ディレクトリやファイルごとにレビュワーを制限することができますが、導入したリポジトリでは運用するチームが限定されているため必要ありません。 リポジトリ単位でレビュワーを制限するなら Collaborators の設定 で 運用チームだけにApprove できる権限を絞れば良いと言う判断になりました。

どうしても code owner を使いたい場合は、machine user を利用する方法があります。 machine user は Organization に対して1つ作成できます。

GitHub アカウントの種類

作成した machine user を code owner に指定し、PATの権限を利用し gh コマンドで Apporve をすると code owner の要件を満たすことができます。

どのような方法を使うかは各チームやリポジトリの要件に応じて決めると良いでしょう。

権限が不足して GitHub Container Repository からイメージを pull できない

チームではGitHub Actions のワークフロー上でテスト環境を準備するために、 GitHub Container Repository から専用のイメージを pull して使用しています。 使用するイメージは社内でプライベートに公開しているイメージなので、アクセストークンで認証する必要がありました。

このアクセストークンに GITHUB_TOKEN を使ったのですが、イメージが pull できませんでした。 原因は GITHUB_TOKEN の permission の scope が不足していることでした。

workflow で使い捨ての GITHUB_TOKEN という認証トークンが発行されますが、ワークフローを動かすためには permissions キーで必要な scope を設定する必要があります。

以下のように指定することで解決しました。

permissions:
  packages: read

jobs:
  ci:
    services:
      db:
        image: ghcr.io/classi/xxx
        credentials:
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

まとめ

今回はチームで導入したライブラリアップデートの自動化の仕組みとその経緯について紹介しました。 特別なものではありませんが、少しの工夫で日々の時間を節約できたのは良い成果だと感じています。

今後もこのような開発・運用を改善する取り組みについて紹介していきたいと思います。

データサイエンス×教育:取り組みから実感した教育分野のPoCのおもしろさ

こんにちは。データサイエンティストの石井です。 今回は、複数の問題を組合せた問題の集合(以降、問題集という)を推薦するアイデアのPoCに取り組みました。今回の取り組みの中ではこのアイデアが有効かの判断には至りませんでしたが、具体的にどのような取り組みを行ったのか、またそこで直面した教育分野のPoC特有のおもしろさについて紹介します。

なぜ問題集の推薦に取り組むのか

2023年5月31日に公開したプレスリリースのとおり、新しい学び支援機能としてAI搭載の「学習トレーニング」機能の提供を始めました。

参考:生徒の自律性とAIによる個別最適な学びを両立する「学習トレーニング」機能を6月にリリース

この機能に搭載されているAIは、生徒一人一人に合わせた問題を1問ずつ推薦するものです。 また、このAIは問題に取り組むごとにそれまでの解答データから「その生徒にある問題を出題した時に何%の確率で正解できそうか」を計算し、その予測正答確率の値にもとづき、難しすぎず易しすぎない問題を推薦します。 このAIを搭載した「学習トレーニング」機能では、特に取り組む問題数の上限を設けず、何問でも推薦された問題に取り組むことができます。 一方、私の学生時代を振り返ると、ページ数や学習時間をあらかじめ決めて学習に取り組んでいた記憶があります。そこで、もしあらかじめ問題数が決まっているのであれば、その問題数の問題を選定し、学習に取り組む方がより効果の高い学習ができるのではないか、と考えました。 このような考えから、「生徒の実力に合わせて問題選定された問題集の推薦は、既存の1問ずつの推薦より、高い学習効果が得られるか」という仮説を立てました。

問題集編成のアプローチ

数理最適化技術を用いたモデルで、Classiに搭載されている大量の問題からいくつかの問題を選択し、問題集を編成していきます。編成したい問題集の設問数に応じて、どの難易度の問題をどの順序で出題するかを考慮します。例えば、ある生徒には対象生徒の実力より難しい問題を多く出題する場合(下図の例①)、また、ほかのある生徒には対象生徒の実力より易しい問題を多く出題する場合(下図の例②)など、設問と難易度の関係(以降、理想の難易度分布という)については、さまざまなケースが考えられます。この理想の難易度分布と選ぶ問題の難易度の差を最小化する整数計画問題を解くことで問題集を編成します。

理想の難易度分布例

モデルの詳細

定式化

与えられた問題集合を  I とし、編成する問題集の設問番号の集合を  J とします。 変数  x_{ij} を問題  i を問題集の設問  j に採用するなら1、非採用なら0となる2値変数とします   ( i \in I, j \in J, |I| \ge |J|)。 理想の難易度分布  f(j) (j \in J) 、問題  iの難易度  d_{i} を用いて、制約: |f(j) - d_{i}|x_{ij} \leq \delta ( \forall i \in I , \forall j \in J) を満たす  \delta の最小化を目的関数とします。 また、そのほかの制約には以下があります。

ある設問に1つの問題を割り当てる制約:

 \displaystyle \sum_{i \in I} x_{ij} = 1 ( \forall j \in J )

ある問題はたかだか1つの設問にしか割り当てられない制約:

 \displaystyle \sum_{j \in J} x_{ij} \le 1 ( \forall i \in I )

以上のことを整理すると、解くべき整数計画問題は次のようになります。

 \displaystyle
\begin{eqnarray}
& \text{Minimize}
& \delta \nonumber
\\
& \text{s.t.}
& |f(j) - d_{i}|x_{ij} \leq \delta \left( \forall i \in I , \forall j \in J \right) \nonumber
\\
&& \sum_{i \in I} x_{ij} = 1 \left( \forall j \in J \right) \nonumber
\\
&& \sum_{j \in J} x_{ij} \le 1 \left( \forall i \in I \right) \nonumber
\\
&& x_{ij} \in \left\{ 0, 1 \right\} \left( \forall i \in I, \forall j \in J \right). \nonumber
\end{eqnarray}

マッチングのアルゴリズムにもとづく高速な解法

快適なサービス提供のために、ある単元に含まれる問題数  |I| = 1853 , 代表的なテストの設問数  |J| = 10の設定で、0.7秒以内に解を求めることを目標にしました。 しかし、先の定式化を汎用ソルバーCBCで解くと約26秒を要し、さらに問題数や設問数が多い場合には解を求めることができませんでした。 そのため、マッチングのアルゴリズムにもとづく高速な解法を検討しました。 この解法では、全設問への割り当てが存在するような難易度のずれの許容値  \delta について、その最小値を二分探索で求めることを考えます。 与えられた  \delta に対し、全設問への割り当てが存在するか否かを判定するには、まず、与えられた  \deltaで2部グラフを構成します。 次に、最大マッチングを求め、そのサイズが設問数と一致していれば、全設問への割り当てが存在することになり、そうでなければ所望の割り当ては存在しないことになります。  \delta が最小のときの全設問への割り当ては、上で求めた最大マッチングの結果から求めることができます。

マッチングのアルゴリズムにもとづく高速な解法

この解法を用いて、設問数を10問で固定し問題数を変動させるパターンと、問題数を1853問で固定し設問数を変動させるパターンの2つの実験を行いました。 この解法では、下表のように、想定する問題数・設問数に対して0.7秒未満で求めることができ、さらに想定以上の問題数・設問数でも解を求めることができました。

数値実験結果

PoCの実施

今回のPoCで、本来明らかにしたいことは「生徒の実力に合わせて問題選定された問題集の推薦は、既存の1問ずつの推薦より、高い学習効果が得られるか」、さらに、学習効果が高い場合に「どのような生徒に、どのような理想の難易度分布で編成した問題集が学習効果を最大化するのか」です。

学習効果があるか判断するためには、ある程度の期間でこの問題集を用いて学習に取り組んでもらう必要があります。また、その期間中はほかの学習コンテンツを使用せず、この問題集のみで学習してもらう必要があります。日々、学校や塾などで所定のカリキュラムで学習する生徒に対して、この検証環境を作ることは現実的ではありません。

そこで今回は、数時間の検証でこの学習効果を測定できないか、検討しました。 以下のタイムスケジュールで検証を行い、事前・事後テストの結果の差から学習効果を測ります。

  • 事前テスト:30分(10問)
  • 問題集での学習①:約45分(10問)
  • 問題集での学習②:約45分(10問)
  • 事後テスト:30分(10問)

学習①では事前テストの結果から、学習②では学習①の結果から、問題集の難易度を決定します。また、理想の難易度分布は、下図のような、実力より易しい問題から始まり、中盤は実力より少し難しい問題を多く出題し、終盤に実力より難しい問題を出題する分布としました。

PoCで使用した理想の難易度分布例

事前テストと事後テストで出題される問題は異なる等質なテスト(ある受験者に対し、同一の評価ができるテスト)を使用します。なお、この事前テスト・事後テストも整数計画問題として定式化し、編成しました。学習効果がある場合は、短期間で結果に出るように事前・事後テストと問題集の出題範囲を1つの単元に絞りました。今回は「数学A-場合の数」で実施しました。学習効果は、事前・事後テストの解答の正誤データをもとに、IRT(項目反応理論)にもとづき推定した能力値の増減で判定します。

検証の結果、1人目の能力値は 1.87→0.72と減少し、2人目の能力値は-0.33→1.02と増加しました。1人目の減少の理由はいくつか考えられます。

  • 事前・事後テストの問題数が不足しており、正確に計測できなかった可能性
  • 事前・事後テストが等質なテストではなく、正確に計測できなかった可能性
  • 「数学A-場合の数」は全列挙で正答できてしまい、検証対象の単元として不適切であった可能性
  • 学習時間が90分では足りず、実際に学習効果がなかった可能性

一方、2人目は正誤結果に違和感なく、能力値も増加しました。今回の事前検証から、短期間での検証で学習効果を計測できる可能性はあると感じています。しかし、教育におけるPoCの難しさを痛感し、短期間で学習効果を計測するPoCを行うには改善が必要だと思われます。

2人目の被験者の正誤結果

教育におけるPoCのおもしろさ

教育分野の学習コンテンツのPoCにおいて、その学習コンテンツの有効性を十分に判断するためには、学習効果を測る必要があります。しかし、上で述べたように学習効果を測ることは容易ではありません。学習効果を測る際には以下の観点のバランスをみて総合的に決定していく必要があります:

  1. 検証期間: 学習効果があるかを判断するために必要十分な学習期間を設定しなければなりません。期間が短すぎる場合は学習効果が十分に判断できず、反対に期間が長すぎる場合は後述の検証環境の構築が難しくなります。

  2. 出題範囲: 教科や単元(例:因数分解や場合の数など)の出題範囲が必要十分でなければなりません。出題範囲を広げた場合はそれだけ検証期間を要することとなり、出題範囲を狭めた場合は設定した単元に依存した検証結果となってしまいます。

  3. 被験者と検証環境: 純粋な学習効果を計測するためには、検証期間中、被験者を学校や塾などのほかの環境での学習から切り離さなければなりません。

  4. 学習効果の測定方法: 学習効果を測定する一般的な手法である「テスト」を使用するか、また、テストで行う場合には事前・事後テストが等質でなければなりません。

これらは、教育分野の学習に関するPoCにおいて重要な観点であり、決定するのは非常に難しいです。しかし、このような難しさが教育分野におけるデータサイエンスのおもしろさでもあります。教科や単元により、問題設定からモデルの構築までまったく異なる教育分野はデータサイエンスの宝庫です。

最後に

ClassiにおけるPoCの取り組みと、教育分野のPoC特有のおもしろさについて紹介しました。今回の数理最適化技術を応用したモデルの開発は、学校の先生や生徒との継続的な対話とモデルの改善を繰り返し行い、さらに、東京理科大学の池辺淑子准教授、西田優樹助教、法政大学の鮏川矩義准教授から数理最適化技術に関してアドバイスをいただき、共同で開発を進めました。このように、Classiでは生徒により良い学習体験を提供するために、学校の先生・生徒、そして学術機関と協力し、多くの取り組みを積極的に行っています。また、Classiではこのほかにも生成AIを用いたコンテンツ制作やClassiにおける学習データでの成績評価など、さまざまな取り組みを行っています。教育分野におけるプロダクト開発に興味をお持ちの方は、ぜひ採用ページをご覧ください。皆様からのご応募をお待ちしています。

https://hrmos.co/pages/classi/jobs

実践OpenTelemetry

こんにちは・こんばんは・おはようございます、エンジニアのid:aerealです。

この記事では筆者が開発に参加しているサービスの監視フレームワークをOpenTelemetryへ移行した際の体験を紹介します。

OpenTelemetryとは

OpenTelemetry is an Observability framework and toolkit designed to create and manage telemetry data such as traces, metrics, and logs.

What is OpenTelemetry?

サイトの説明にある通り分散トレースやメトリクス、ログなどの指標を扱う監視フレームワークです。

OpenTracingやOpenCensusなどを継承・統合したプロジェクトと言うと合点がいく方も多いのではないでしょうか。

OpenTelemetryは、指標や属性などの意味論やプロトコルを定めた仕様、それら仕様を実現する各言語の実装、そしてプロトコルに従いデータを処理・送受信するCollectorの計3つからおおまかに成り立っています。

アプリケーションにSDKを組み込み、サイドカーもしくはゲートウェイとして配置したCollectorに対し各指標を送り、Collectorは設定で記述された通りに指標を加工し外部サービスへ送信するという流れになっています。

アプリケーションはSDKを介してOpenTelemetryプロトコルでCollectorに送信することだけに関心を持てばよく、その指標がどのように外部サービスへ送信されるかはCollectorへ任せられるという点が各ベンダのSDKをそれぞれ利用する場合に対する利点です。

またCollectorはバッファリングだけではなく、属性 (attributes) と呼ばれる指標のメタデータを追加・加工・削除する仕組みや、属性をもとに送信先を変えたり、送信された指標を集積してカウンタ値を生成するといった高度な仕組みも備えています。

Collectorは公式に提供されるリリースビルドのほか、組み込むコンポーネントをカスタマイズしてビルドする方法が提供されており、組織内部で共通して利用したいコンポーネントの組み込みが比較的簡単になっています。 この点に関してもまた機会を改めて紹介したいと思います。

Classiで採用した理由

当社で新規開発したサービスはAWSのマネージドサービスを積極的に取り入れており、コードを書いて構築するアプリケーションとマネージドサービスの境界は曖昧になってきているくらい対等な存在になっています。

refs. dron: クラウドネイティブなcron代替の紹介 - Classi開発者ブログ

そんな複数のコンポーネントからなるアプリケーションを監視する上で分散トレーシングは欠かせません。

Classiでは監視ソリューションとしてDatadogを導入・活用しています:

分散トレーシングもDatadog APMを活用しています。実際、先の記事で紹介したdronをはじめとしたすべてのClassiのサービスではDatadogのSDKを用いてAPMへトレースを送っていました。

一方、Step FunctionsなどのAWSのマネージドサービスは分散トレーシングに対応していますが、トレースの収集先はAWS X-Rayのみです。 トレースをDatadog APMのみに送っているとX-Rayのみに存在するマネージドサービスのトレースと断絶してしまいます。

仕方がなくアプリケーションはAWS X-Ray SDKとDatadog APM SDKの 両方 を導入し、X-RayとDatadog APMの両方へトレースを送ることにしていました。 しかし、トレースのメタデータ追加などあらゆる実装が二度手間になり、実装の抜け漏れも起きやすくなります。

実際、アプリケーションの不調を調べるためにAPMを見たらDatadog APMにはほしいメタデータを送っていなかったということが起き、ほとほと嫌気が差したので二重の計装をやめるべく調査をはじめ、OpenTelemetryに行き着きました。

なおX-Rayへ統一するという選択肢は、アラートの設定など監視全般をDatadogに寄せる方針であったこと・それを差し引いてもDatadogのほうが使い勝手が良かったことから、検討していません。

アプリケーションへの導入

監視SDKとしてOpenTelemetryへ移行することに決めたあとにやることは至って単純で、ドキュメントに従いライブラリを導入し、OpenTelemetry Collectorをサイドカーに追加し、動作確認をします。

移行中の基本的な動作確認する際は、実際にX-RayやDatadogに送るよりもローカルで起動したZipkinを送信先に設定したCollectorを動かすと素早くトライアンドエラーを繰り返すようになりとても重宝しました。

もちろん各exporterの変換処理が挟まること・それら変換が設定によって異なることから、最終的には実際にX-RayやDatadogに送った上で確認すべきですが、たとえばそもそも収集したいスパンが送られているかとか、きちんと属性を追加できているかとか、そういった基本的な確認はZipkinでも十分です。

属性の管理

OpenTelemetryは属性 (attributes) と呼ばれるメタデータがトレースやメトリクスなどの各指標に紐付けられます。

これらはX-Rayではアノテーション・Datadogではタグにそれぞれ変換されます。

属性は指標の情報量を増やし監視を大いに助けてくれますが、アプリケーション全体で統一しておかないといざという時に使えません。

そこで属性を設定ファイルで管理し、属性を注入する実装は設定ファイルから生成した関数だけが行えるようにすることで、アプリケーション全体で使われている属性を統一することにしました。

以下に実際の設定ファイルの例を紹介します:

属性の設定ファイル (telemetry.yml) 例

---
# yaml-language-server: $schema=./telemetry.schema.json
attributes:
  db_migration.is_dirty: { type: bool }
  db_migration.step: { type: int }
  git.commit.sha:
    type: string
    description: |
      現在動いているコミットハッシュを表す属性名。
      Datadogのactive commit linkingに使う。

      refs. https://docs.datadoghq.com/integrations/guide/source-code-integration/?tab=dockerruntime#tag-your-telemetry
  git.repository_url:
    type: string
    description: |
      サービスのソースコードがホストされているリポジトリを指す属性値。
      Datadogのactive commit linkingに使う。

      refs. https://docs.datadoghq.com/integrations/guide/source-code-integration/?tab=dockerruntime#tag-your-telemetry
measurements:
  test_import_count: { type: Int64Gauge }

またスキーマは以下の通りです:

telemetry.schema.json

{
  "type": "object",
  "properties": {
    "attributes": {
      "$ref": "#/definitions/attributes"
    },
    "measurements": {
      "$ref": "#/definitions/measurements"
    }
  },
  "definitions": {
    "deprecationDescription": {
      "type": "object",
      "properties": {
        "reason": {
          "type": "string"
        }
      },
      "required": [
        "reason"
      ]
    },
    "attributeValueType": {
      "enum": [
        "string",
        "bool",
        "int",
        "int64"
      ]
    },
    "attributeDefinition": {
      "type": "object",
      "properties": {
        "type": {
          "$ref": "#/definitions/attributeValueType"
        },
        "deprecated": {
          "$ref": "#/definitions/deprecationDescription"
        },
        "description": {
          "type": "string"
        }
      },
      "required": [
        "type"
      ]
    },
    "attributes": {
      "type": "object",
      "patternProperties": {
        ".+": {
          "$ref": "#/definitions/attributeDefinition"
        }
      }
    },
    "measurementType": {
      "enum": [
        "Float64Counter",
        "Float64Gauge",
        "Float64Histogram",
        "Float64UpDownCounter",
        "Int64Counter",
        "Int64Gauge",
        "Int64Histogram",
        "Int64UpDownCounter"
      ]
    },
    "measurementDefinition": {
      "type": "object",
      "properties": {
        "type": {
          "$ref": "#/definitions/measurementType"
        }
      }
    },
    "measurements": {
      "type": "object",
      "patternProperties": {
        ".+": {
          "$ref": "#/definitions/measurementDefinition"
        }
      }
    }
  }
}

  • attributes.$name.type: 属性の型。stringやintなどOpenTelemetryの仕様に従う
  • attributes.$name.description: 属性の説明。省略可能で、生成されたコードのドキュメントにも含まれる

VS Codeにredhat.vscode-yamlを入れておくと、ローカルに配置したJSON Schemaを読み込んでフィールドの補完を行ってくれます。

以下に上記ファイルから生成されたGoのコードを載せます:

生成されたGoのコード

// KeyDbMigrationIsDirty is an attribute key that means "db_migration.is_dirty".
var KeyDbMigrationIsDirty = attribute.Key("db_migration.is_dirty")

// AttrDbMigrationIsDirty returns a new attribute that named "db_migration.is_dirty".
func AttrDbMigrationIsDirty(v ...bool) attribute.KeyValue {
    switch {
    case len(v) == 0:
        return emptyKeyValue
    case len(v) == 1:
        return KeyDbMigrationIsDirty.Bool(v[0])
    default:
        return KeyDbMigrationIsDirty.BoolSlice(v)
    }
}

// KeyDbMigrationStep is an attribute key that means "db_migration.step".
var KeyDbMigrationStep = attribute.Key("db_migration.step")

// AttrDbMigrationStep returns a new attribute that named "db_migration.step".
func AttrDbMigrationStep(v ...int) attribute.KeyValue {
    switch {
    case len(v) == 0:
        return emptyKeyValue
    case len(v) == 1:
        return KeyDbMigrationStep.Int(v[0])
    default:
        return KeyDbMigrationStep.IntSlice(v)
    }
}

// KeyGitCommitSha is an attribute key that means "git.commit.sha".
//
// 現在動いているコミットハッシュを表す属性名。
// Datadogのactive commit linkingに使う。
//
// refs. https://docs.datadoghq.com/integrations/guide/source-code-integration/?tab=dockerruntime#tag-your-telemetry
var KeyGitCommitSha = attribute.Key("git.commit.sha")

// AttrGitCommitSha returns a new attribute that named "git.commit.sha".
//
// 現在動いているコミットハッシュを表す属性名。
// Datadogのactive commit linkingに使う。
//
// refs. https://docs.datadoghq.com/integrations/guide/source-code-integration/?tab=dockerruntime#tag-your-telemetry
func AttrGitCommitSha(v ...string) attribute.KeyValue {
    switch {
    case len(v) == 0:
        return emptyKeyValue
    case len(v) == 1:
        return KeyGitCommitSha.String(v[0])
    default:
        return KeyGitCommitSha.StringSlice(v)
    }
}

// KeyGitRepositoryUrl is an attribute key that means "git.repository_url".
//
// サービスのソースコードがホストされているリポジトリを指す属性値。
// Datadogのactive commit linkingに使う。
//
// refs. https://docs.datadoghq.com/integrations/guide/source-code-integration/?tab=dockerruntime#tag-your-telemetry
var KeyGitRepositoryUrl = attribute.Key("git.repository_url")

// AttrGitRepositoryUrl returns a new attribute that named "git.repository_url".
//
// サービスのソースコードがホストされているリポジトリを指す属性値。
// Datadogのactive commit linkingに使う。
//
// refs. https://docs.datadoghq.com/integrations/guide/source-code-integration/?tab=dockerruntime#tag-your-telemetry
func AttrGitRepositoryUrl(v ...string) attribute.KeyValue {
    switch {
    case len(v) == 0:
        return emptyKeyValue
    case len(v) == 1:
        return KeyGitRepositoryUrl.String(v[0])
    default:
        return KeyGitRepositoryUrl.StringSlice(v)
    }
}

ご覧の通り設定ファイルに記述した description が関数のコメントに含まれています。

最後に以下のようにアプリケーション内の任意のファイルから go.opentelemetry.io/otel/attribute を用いて設定ファイルで管理されていない属性の追加ができないようgolangci-lintを設定します:

linters:
  disable-all: true
  enable:
    - forbidigo
linters-settings:
  forbidigo:
    forbid:
      - "^attribute[.]"

厳密には attribute. で始まる式が違反となるもので想定よりファジーですが実用上困っていないのでこのままとします。

最後にコード生成に使ったスクリプトを以下に示します:

generate-signals/main.go

package main

import (
    "bytes"
    _ "embed"
    "errors"
    "flag"
    "fmt"
    "go/format"
    "os"
    "path/filepath"
    "sort"
    "strconv"
    "strings"
    "text/template"
    "unicode"

    "gopkg.in/yaml.v3"
)

var (
    //go:embed src.gotpl
    tmplBody string

    pkg        string
    typeName   string
    doFormat   bool
    configPath string
    outPath    string

    errPkgRequired = errors.New("-pkg is required")
    errOutRequired = errors.New("-out is required")
)

func main() {
    if err := run(); err != nil {
        fmt.Fprintf(os.Stderr, "%+v\n", err)
        os.Exit(1)
    }
}

func run() error {
    flag.Usage = func() {
        fmt.Fprintf(os.Stderr, "Usage of %s:\n", filepath.Base(os.Args[0]))
        flag.PrintDefaults()
    }
    flag.Parse()
    if pkg == "" {
        return errPkgRequired
    }
    if outPath == "" {
        return errOutRequired
    }
    parsed, err := template.New("src.gotpl").
        Funcs(template.FuncMap{"quote": strconv.Quote, "prefixLines": prefixLines}).
        Parse(tmplBody)
    if err != nil {
        return fmt.Errorf("template.Parse: %w", err)
    }
    cfg, err := readConfig(configPath)
    if err != nil {
        return fmt.Errorf("readConfig: %w", err)
    }
    attrs := make([]*attribute, 0, len(cfg.Attributes))
    for name, def := range cfg.Attributes {
        def := def
        a := &attribute{Name: name, Type: def.Type, Description: def.Description}
        if def.Deprecation != nil {
            a.DeprecationReason = def.Deprecation.Reason
        }
        attrs = append(attrs, a)
    }
    measurements := make([]*measurement, 0, len(cfg.Measurements))
    for name, def := range cfg.Measurements {
        def := def
        measurements = append(measurements, &measurement{Name: name, DataType: def.DataType})
    }
    sort.Slice(attrs, func(i, j int) bool { return attrs[i].Name < attrs[j].Name })
    sort.Slice(measurements, func(i, j int) bool { return measurements[i].Name < measurements[j].Name })
    out := new(bytes.Buffer)
    data := struct {
        Package       string
        Attributes    []*attribute
        Measurements  []*measurement
        TypeName      string
        SingletonName string
    }{
        Package:       pkg,
        Attributes:    attrs,
        Measurements:  measurements,
        TypeName:      typeName,
        SingletonName: "Measurements",
    }
    if err := parsed.Execute(out, data); err != nil {
        return fmt.Errorf("template.Execute: %w", err)
    }
    body := out.Bytes()
    if doFormat {
        var err error
        body, err = format.Source(body)
        if err != nil {
            return fmt.Errorf("format.Source: %w", err)
        }
    }
    if err := os.WriteFile(outPath, body, 0600); err != nil {
        return fmt.Errorf("os.WriteFile(%s): %w", outPath, err)
    }
    return nil
}

type attribute struct {
    Name              string
    Type              string
    DeprecationReason string
    Description       string
}

func (a *attribute) inputType() (elmType string, isSlice bool) {
    t, ok := strings.CutPrefix(a.Type, "[]")
    return t, ok
}

func (a *attribute) ElemType() string {
    et, _ := a.inputType()
    return et
}

func (a *attribute) AttributeConstructor() string {
    cn, _ := a.inputType()
    return strings.ToUpper(string(cn[0])) + cn[1:]
}

func (a *attribute) AttributeSliceConstructor() string {
    return a.AttributeConstructor() + "Slice"
}

func (a *attribute) GoType() string {
    return a.Type
}

func (a *attribute) Identifier() string {
    b := new(strings.Builder)
    shouldUpNextRune := true
    for _, r := range a.Name {
        switch {
        case shouldUpNextRune:
            b.WriteRune(unicode.ToUpper(r))
            shouldUpNextRune = false
        case r == '_' || r == '.':
            shouldUpNextRune = true
        default:
            b.WriteRune(r)
        }
    }
    return b.String()
}

type measurement struct {
    Name     string
    DataType string
}

func (m *measurement) QualifiedName() string {
    return "app." + m.Name
}

func (m *measurement) FieldName() string {
    b := new(strings.Builder)
    shouldUpNextRune := true
    for _, r := range m.Name {
        switch {
        case shouldUpNextRune:
            b.WriteRune(unicode.ToUpper(r))
            shouldUpNextRune = false
        case r == '_':
            shouldUpNextRune = true
        default:
            b.WriteRune(r)
        }
    }
    return b.String()
}

func (m *measurement) SDKReturnType() string {
    switch m.DataType {
    case "Int64Gauge":
        return "Int64ObservableGauge"
    case "Float64Gauge":
        return "Float64ObservableGauge"
    default:
        return m.DataType
    }
}

type deprecationDescription struct {
    Reason string `yaml:"reason"`
}

type attributeDefinition struct {
    Type        string                  `yaml:"type"`
    Deprecation *deprecationDescription `yaml:"deprecated"`
    Description string                  `yaml:"description"`
}

type measurementDefinition struct {
    DataType string `yaml:"type"`
}

type telemetryConfig struct {
    Attributes   map[string]*attributeDefinition   `yaml:"attributes"`
    Measurements map[string]*measurementDefinition `yaml:"measurements"`
}

func readConfig(cfgPath string) (*telemetryConfig, error) {
    f, err := os.Open(cfgPath)
    if err != nil {
        return nil, err
    }
    defer f.Close()
    cfg := new(telemetryConfig)
    if err := yaml.NewDecoder(f).Decode(cfg); err != nil {
        return nil, err
    }
    return cfg, nil
}

func prefixLines(prefix, s string) string {
    return prefix + strings.ReplaceAll(s, "\n", "\n"+prefix)
}

func init() {
    flag.StringVar(&pkg, "package", "observability", "package name")
    flag.StringVar(&typeName, "type", "MeasurementDefinition", "type name")
    flag.StringVar(&configPath, "config", "etc/telemetry.yml", "config file path")
    flag.BoolVar(&doFormat, "format", true, "whether do format")
    flag.StringVar(&outPath, "out", "", "output file path")
}

src.gotpl

package {{ .Package }}

// Code generated by tools/gen-telemetry-signal/main.go; DO NOT EDIT.

import (
  "go.opentelemetry.io/otel/attribute"
  "go.opentelemetry.io/otel/metric"
)

var emptyKeyValue attribute.KeyValue

{{ range $attr := .Attributes }}
// Key{{ $attr.Identifier }} is an attribute key that means {{ $attr.Name | quote }}.
{{- with $attr.Description }}
//
{{ . | prefixLines "// " }}{{ end }}
{{- with $attr.DeprecationReason }}
//
{{ (. | printf "Deprecated: %s") | prefixLines "// " }}{{ end }}
var Key{{ $attr.Identifier }} = attribute.Key({{ $attr.Name | quote }})

// Attr{{ $attr.Identifier }} returns a new attribute that named {{ $attr.Name | quote }}.
{{- with $attr.Description }}
//
{{ . | prefixLines "// " }}{{ end }}
{{- with $attr.DeprecationReason }}
//
{{ (. | printf "Deprecated: %s") | prefixLines "// " }}{{ end }}
func Attr{{ $attr.Identifier }}(v ...{{ $attr.ElemType }}) attribute.KeyValue {
  switch {
  case len(v) == 0:
    return emptyKeyValue
  case len(v) == 1:
    return Key{{ $attr.Identifier }}.{{ $attr.AttributeConstructor }}(v[0])
  default:
    return Key{{ $attr.Identifier }}.{{ $attr.AttributeSliceConstructor }}(v)
  }
}
{{ end -}}

// measurements

func {{ .SingletonName }}() {{ .TypeName }} {
  return __singleton_{{ .SingletonName }}
}

var __singleton_{{ .SingletonName }} = {{ .TypeName }}{
  {{- range $measurement := .Measurements -}}
    {{ $measurement.FieldName }}: {{ $measurement.QualifiedName | quote }},
  {{- end -}}
}

type {{ .TypeName }} struct {
  {{ range $measurement := .Measurements }}
    {{ $measurement.FieldName }} string
  {{- end -}}
}

{{ range $measurement := .Measurements }}
func Measure{{ $measurement.FieldName }}(meter metric.Meter) (metric.{{ $measurement.SDKReturnType }}, error) {
  return meter.{{ $measurement.SDKReturnType }}({{ $measurement.QualifiedName | quote }})
}
{{ end -}}

むすび

AWS X-RayおよびDatadog APMへトレースを送る仕組みとしてOpenTelemetryを採用・移行した経緯と具体的なエピソードを紹介しました。

他にもOpenTelemetryを活用する上で様々な取り組みをしているので今後も紹介したいと思います。

テスト自動化〜運用編〜

こんにちは!Classi QAチームの池田です。

今回は、Classiで取り組んでいるテスト自動化の運用についてご紹介します!

弊社ではテスト自動化ツールにAutifyを使用しており、テストを自動化したお話についてはこちらの記事で紹介しておりますので、よろしければご覧ください!

運用の取り組み①定期実行

弊社のQAエンジニアは、各開発チームに「専任QA」として配属されています。 私が専任QAとして所属している開発チームの担当領域は「設定・登録」です。

自動化を進める前は定期的なリグレッションテストが行われていなかったため、自動化したテストを定期実行し、品質の担保に貢献していきたいと考えていました。

また、長い時間を要してしまう手動でのリグレッションテストを自動化することで、検証時間の削減にも繋げていきたいと考えました。

自動テストを作成し始めてからは定期的に開発チームに進捗を共有していたのですが、「できてるシナリオからどんどん回していけば?」と提案していただいたので、完成したシナリオから定期実行を始めました。

定期実行に含まれるシナリオの一部

「設定・登録」では、先生や生徒の情報を登録したり、授業や時間割を設定することができます。 そのため自動化のシナリオ内では各種設定や情報を操作することが多いのですが、その際シナリオの順序性により不都合が出てしまうことがありました。

例えば、シナリオを並列実行することにより、あるシナリオの情報変更操作が原因で並列実行しているシナリオが失敗してしまう、途中で実行失敗しシナリオ内で削除されるべきデータが削除できず後続のシナリオに影響が出て失敗してしまう、などの事象が立て続けに起こりました。

そのため、実行は並列から直列に変更、他に影響を及ぼす可能性のあるシナリオの実行順序を後回しにする、などの改善を行いました。

定期実行に関する資料の一部

成果としては、定期実行により障害を検知することができました。

開発チームは不定期にリリースを行っており、ライブラリのアップグレードなどのQAを通さない簡素なリリースもあります。 そのリリースで影響範囲の考慮が一部漏れており障害が出てしまったのですが、定期実行で検知しユーザーからの問い合わせが入る前に対応することができました。

運用の取り組み②開発のテスト工程での自動テスト活用

リリース時のQA検証としても、自動テストの活用を開始しました。 QA検証の際には、影響範囲である機能をリグレッションテストとして実行しています。

自動テストを実行している間に手動項目を検証できるので検証時間短縮に繋がっています。自動テストを活用し始めてから、月10時間ほど検証時間を短縮できた時期もありました。

また、リリース内容によっては自動テストだけで完了できるものもあります。 その場合には自動テストを実行しながら別作業ができるので、より効率的に仕事ができるようになりました。

運用の取り組み③シナリオ改善作業

自動テストを作成後、既存機能に修正が入った場合や、新機能や画面に新しい文言が追加された場合などの状況に応じて修正や改善作業を行っています。

文言を確認するステップを自動テストに入れている場合、UIの僅かな変化でも自動テストが失敗してしまうことがあるので注意が必要です。

また開発の都合上、別の開発チームが担当領域の改修等を行うことがあるため、自動テストの修正要否を判断するために、他のチームのリリース情報を随時キャッチアップするようにしています。

運用していくにあたり、自動テストの改善も必要です。 内容によっては実行に時間がかかってしまったり、よく失敗してしまうテストもあります。 どうしたら実行時間をより短縮できるか、どうしたらテストを安定させられるか、という観点で改善していくことが多いです。自分達だけで解決が難しそうな場合は、ツールのカスタマーサポートに問い合わせて助けていただくこともあります。

例えば、自動テスト内で情報の入力や選択をする際、要素を探索する時間が長くなってしまう場合があります。そういった場合、CSSセレクターを利用して要素を選択し時間短縮を行う方法があります。ただし、CSSセレクターの中にテストのたびに変わってしまうclassの値などが入っているとテストが失敗してしまうため、注意が必要です。

CSSセレクターを指定したステップの例

運用の取り組み④ナレッジ蓄積

自動テストを運用するにあたり、ナレッジの蓄積も重要となります。

自動テストを作成から運用までしていると、情報や知識が属人化しやすくなります。自分以外のメンバーが対応することになってもいいようにナレッジを蓄積していく必要があると感じました。

自動テストシナリオの一例

以下、ドキュメントを残す際の観点の一例です。

  • 自動テスト運用の概要
  • シナリオ一覽
  • データ編集内容
  • 使用している環境/アカウント
  • 注意事項

このドキュメントを見れば、仮に自分が不在の場合でも他のメンバーが運用の内容を把握し、対応できるようにしています。

ドキュメントの一部

運用の取り組み⑤開発メンバーへの自動テスト解放

そして、今年度から取り組んでいる運用が「開発メンバーへの自動テスト解放」です!

昨年度までに全ての開発チームに対するテスト自動化がほぼ完了しました。 次のステップとして、QAチーム内のみで管理していた自動テストを開発チームにも解放し、開発チームも自動テストを使ってテスト工程で検証できる体制作りを進めています。

狙いとしては開発チームのリリース頻度向上、およびQAチームの工数削減を目的としています。

開発チームが自動テストで検証することで、QAチームに依頼する工数が削減できリリースしやすい状況になること、且つQAチームも検証にかかる工数を削減できるようになることを想定しています。

現在、一部のチームから解放するトライアルを実施しています。解放対象は既にQAが作成した自動テストの実行のみで、自動テストのメンテナンスや作成は引き続きQAメンバーが対応していますが、今後の状況によっては解放範囲も変わってくるかなと思います。

現在はトライアルの段階ですが、開発メンバーへヒアリングを実施して運用の改善に努めていく予定です。

運用フローの一部

さいごに

以上、Classi QAチームのテスト自動化の取り組みをご紹介しました!

どの運用もまだまだ試行錯誤している段階のため、課題やフィードバックを基により良い運用になるよう努めていきたいと思います!

プロダクト本部の誕生と組織変更の狙い

みなさん、こんにちは。プロダクト本部 本部長のid:tetsuro-ito です。Classiでは、8月に組織体制を再編して新たな組織体制に移行しましたので、今日はその内容と狙いを説明します。 以前のブログでも組織に関する話を紹介しており、その継続的な話になるため、あらかじめ参考記事として紹介しておきます。

なぜ組織変更をしたか

昨年の記事でチームトポロジーを参考に組織編成を考えた話を紹介しました。当時も念頭においていたのはプロダクトの開発をよりスムーズに行えるようにし、事業目標を達成しにいくことが狙いとしてありました。2022年度のClassi社内の認識として、2020年のインシデント・高負荷障害を乗り越え、サービスもきちんと安定稼働してきたことから、いわゆるサバイバルモードを抜け出し、ラーニングモードの中で事業目標を達成しにいくつもりでした。

しかし、半年間そうした組織で運用して行った結果、狙いとは裏腹にあまりうまくいきませんでした。ClassiではICT教育の環境変化に伴って、学習系のサービスに注力をしていく戦略を取っています。2023年度にはこれまでの学習機能を刷新して、新たに学習トレーニングという機能をリリースしていく想定でしたが、その開発に黄色信号が灯ってしまう状態でした。そのため方針を転換し、まずはきちんとこの学習トレーニングを開発し、お客様に届けられるような体制へシフトしました。その甲斐もあって、今期は無事に機能のリリースができています。

また、以前よりClassiではマトリクス組織という組織形態を採用していました。エンジニアやデザイナー、マーケターなどの職能を束ねる組織とClassiの各機能を担当するファンクションチームの縦横の組織でした。職能ごとに切り分けた結果、デザイナーやデータ系の組織などを誕生させることができ、メリットもあった一方で指揮系統が2箇所あることで、事業目標に向けた動きが難しかったり、情報連携にも課題があるなど、いくつかの課題もありました。

どのように組織を変更したか

組織変更のbefore/after

今回はClassiのプロダクトに関わる組織を大きく再編しました。上記の図の通り、これまでディレクターやデザイナーの職能を統括していたプロダクトデザイン本部(プロデザ本部)とエンジニアやデータサイエンティストを統括していた開発本部、プロダクトマーケティングに携わっていたプロダクトマーケティング部(PMM部)を一つの本部に統合し、それをプロダクト本部と名付けました。プロダクト本部は3つの部署からなる本部で学習PMF部、Growth部、プラットフォーム部の3つの部署で構成されています。

学習PMF部

まず、学習PMF部についてです。PMFとはProduct Market Fitの略語で、プロダクト開発やマーケティングの分野で用いられる用語です。前述したようにClassiでは、新たな学習体験を顧客へ届けるために学習トレーニング機能をリリースしています。この学習トレーニング機能をきちんと開発し、市場に受け入れられ、活用していくことが主なミッションとなります。この部に所属する開発グループとして、学習トレーニング機能を開発するチームや、学習コンテンツを搭載する機能を開発するチーム、コンテンツを作成し搭載するチーム、個別最適化学習のレコメンドエンジンを開発するチームなどが所属しています。

Growth部

次はGrowth部についてです。こちらの由来もグロースハックなどのグロースで既存のサービスを育てていく意味合いがあります。こちらは主にClassiの既存機能を担当する開発グループが所属しています。コミュニケーション機能やコーチング機能、サービスの利用開始の設定登録や認証の機能など、さまざまなものがあります。こちらの部署では、既存の機能を改善し、顧客によりよく利用してもらうことを主なミッションとしています。

プラットフォーム部

最後にプラットフォーム部です。こちらはその名の通り、サービスの基盤のようなものを担う組織です。主な機能としてインフラ/SREやQA(Quality Assurance)の機能を持った組織が所属しています。

以前の記事でチームトポロジーの概念を参考にしていると紹介しましたが、その観点でいうと、ストリームアラインドチームは学習PMF部とGrowth部で、プラットフォーム部はシステムプラットフォームチームの位置付けとなります。

プロダクトボードの導入 *1

プロダクトボードと横断ボード

今回、各部の意思決定の仕組みとしてプロダクトボードという仕組みを構築しました。ここはこれまでプロダクトマネージャーが担う職責でしたが、一人にかかる負担が高く、専門性も広く見切れないという課題がありました。それに対して、プロダクトマネジメントをそれぞれの職能のリーダーが分業で行うプロダクトボードという仕組みを採用し、それぞれの職能の合議をもって意思決定をしていくようにしています。

プロダクトボードの構成として、プロダクトマネジメントトライアングルを参考にしており、エンジニアリングサイドのリーダー、顧客サイドのリーダー、ビジネスサイドのリーダーを配置し、良い綱引きができるような体制です。まだまだ理想的な配置ができているかといえば、まだまだですが、ここをこれから育成し、きちんとしたプロダクトマネジメントが行える体制としていきたいと考えています。

また、これまでの課題として、ファンクションチームで構成されていたため、Classi全体のプロダクトの方向性を作り上げる組織が不明確でした。そこで今回は、それぞれのプロダクトボードの上に横断ボードという会議体を設置し、事業の計画やプロダクトロードマップの検討、それぞれの開発のモニタリングなどを検討できるようにしています。

Classiは2025年のビジョンとして、

先生とともに、学びから学ぶ仕組みを創り、ワクワクする子供を増やします

というビジョンを掲げています。今回の組織変更はこのビジョンの達成に向けて一丸となって動いていけるようにしているつもりです。今後も運用を続ける中でさまざまな困難が出てきて、都度見直すこともあるでしょうが、それも全てClassiの掲げるミッションやビジョンを達成するためです。

このような取り組みに共感いただける方は、ぜひとも当社の採用ページより話を聞きにきてください。

採用ページ

*1:プロダクトボードはエス・エム・エス社の取り組みを参考にしています

【開発者インタビュー #4】小川 耀一朗

こんにちは!Classiで働く開発者インタビューシリーズ企画の第4回は、データAI部の小川さんです。

まず簡単に自己紹介をお願いします

2020年4月に新卒でClassiに入社しました。開発本部のプロダクト開発部に配属され、その後2022年7月に同じ開発本部のデータAI部に異動しました。

ー どのような経緯でデータAI部に異動したのですか?

大学で機械学習に関する研究を行っていたため、就職活動の時からデータAI部に興味がありました。ただ、プロダクト開発にも興味があったり、当時は配属がプロダクト開発部しかなかったため、まずはソフトウェアエンジニアとしてプロダクト開発部に配属となりました。その時からいずれはデータAI部に入りたいと思っていましたね。

異動希望をお願いしていて相談の結果データAI部に異動*1することとなりました。今振り返ると最初はプロダクト開発部でソフトウェアエンジニアとして学べたことはとても良かったと思っています。

ー 最初の配属がプロダクト開発部で良かったと思うのはなぜですか?

Classiがどうやって動いているのかなど、開発を経験したからわかることが沢山あると感じています。 特に現在担当しているCALEはClassiのサービスとの接続が強いため、経験がとても役に立っています。 自分の成長や経験に合わせて、入社時に相談し想定していたキャリアステップを進むことができているなと思っています。

Classiへの入社経緯、入社理由は?

大学で自然言語処理の研究をしている中で、それを応用して教育や学校現場に役に立つものが作りたいと思っていました。就活で逆求人イベントに参加した時にClassiと出会い「ここだ!」と思って決めました。

ー なぜ教育だったのでしょうか?

言語習得や言語教育を支援するための研究がテーマで、その中で自然言語処理を用いて文法の誤りを訂正するツールを作っていました。それを、言語学や教育現場の先生などが集まるような教育関連の学会で発表した時に、実際の現場を知る方々に「こういうものがあると嬉しい!」と声をかけていただいたことがきっかけでした。

ちなみに、研究室選びの時は「プログラミングをやりたい」という気持ちぐらいで教育に興味があったり何かやりたいことがあるわけではありませんでした(笑)。プログラミングができる研究室に入った結果、教育のテーマを持っている研究室だったため、その中で自分は文法誤り訂正を研究テーマに選んだ、という偶然な出会いですね。

ー Classiに決めた具体的なポイントはどのようなところでしたか?

Classiのミッションがいいなと思ったところですね。

教育学会で先生方とお話をしている中で、その先生方の業務の負荷が高いということを知りました。Classiを知った時、自分ができるプログラミングや言語処理を使って、全国の先生方に貢献できるんだなと思いました。自分の想いがミッションに体現されていると感じました。

プログラミング教育などにも興味はありましたが、まだまだ自分が学ばないといけないことが沢山あって人に教えるほど詳しくないので、それは将来的にやっていきたいなと思っています。

Classiでの仕事内容を教えてください

プロダクト開発部として、EC2からECSへの移行や、CALEというレコメンドエンジンをWebテストのおすすめ演習機能として搭載することを行いました。 データAI部に異動してからは、学習トレーニング機能にCALEを連携したり、CALE自体の開発・運用・検証を行なっています。

※その他CALE関連の記事はこちらをご覧ください。 tech.classi.jptech.classi.jp

ー Webテストおすすめ演習機能と学習トレーニング機能についてもう少し詳しく教えてください。

どちらも先生が課題を配信して生徒が問題を解いて学ぶ機能です。Webテストは主に模試対策を重視したコンテンツ、学習トレーニングは模試の範囲にも対応しつつ、日常的な学習にも使えるコンテンツとなっています。 CALEによって生徒さんの回答状況に合わせて個別最適化した問題をレコメンドできます。先生方が直接回答状況を確認したり、問題を出したりしなくてもシステムで学習トレーニングに取り組めるようになりました。

ー プロダクト開発部からデータAI部に異動したことで何か変化はありましたか?

データAI部に異動したことでやはりデータという観点で検討する意識が強くなりました。例えば、リリースした機能が本当に使われているかということをデータから確認したり、ユーザーの課題をデータから特定できないかを考えたりするようになりました。また、データを活用するためのデータ基盤やデータ連携についても興味を持つようになりました。

Classiでの仕事の面白さや、やりがいについて教えてください。

データから利用状況を把握し、データに基づいてユーザーの課題を見つけ「仮説を立てる→検証する→修正する→改善」と繋げていくことに面白さややりがいを感じます。まだまだ課題は多いですが、着実に改善していきより良いプロダクトに成長させたいと思っています。 また、リリースした機能がちゃんと使われているということも嬉しいです。

ー 具体的にどんなデータを見ているのですか?

TableauやRedashのダッシュボードのグラフを毎日見ています。 Webテストの機能を例に上げると、テストを配信している学校数、テストが完了している学校数、テスト後のレコメンドした問題への取り組み状況など、さまざまなデータを見ています。 模試の前は利用率が上がったりしているのがわかったり、色々なことがデータから読み取ることができてとても面白いです。

日次利用学校数

ー 嬉しかったリリースはどのようなものがありますか?

1月にリリースした絞り込み機能ですね。 Webテストのおすすめ問題の先生画面の改善で、生徒のおすすめ問題の回答状況を確認する画面に学年やクラスで絞り込みをしたり、並べ替えができる機能をリリースしました。ダッシュボード上ではなかなか効果が見えにくい部分ではあったのですが、データだけでは見えない機能を使った現場の先生の嬉しいお声もいただいたりもして、とても嬉しかったですね。その他にも色々な機能改善のリリースを行っていて、ユーザーの声が届くことはとても嬉しいです。

WEBテストおすすめ問題取の先生画面(赤枠:改善箇所)

Classiでの仕事の難しさや課題について教えてください。

少し矛盾する部分もありますが、まだデータからはなかなか見えにくいことも多いという難しさを感じます。例えば、生徒に学習内容を定着させたいと考えたとき「定着したかどうか」を何をもって判断できるのか、など非常に難しいです。

ー なぜデータからは見えにくい部分も多いのでしょうか?

まだ教育業界ではデジタルよりも紙がまだ強いのが現状です。例えば先生はクラスの模試の偏差値変化を紙でチェックし、情報をそのまま紙で残していることも多いです。そのため直接先生や生徒さんにヒアリングして定性情報を集めてわかることも多く、Classiを使ってくれている人の感情や状況などの理解を深めていかないといけないと思っています。

一方で、マストハブサーベイを取得するなど、先生の声を定量情報として取得していけるようにも動いています。

働く上で大切にしていることは何ですか?

Classiのミッションをもじる形ですが「自分の無限の可能性を解き放つこと」です。自分で自分を制限せず、まずはやってみることを大切にしています。Classiのミッションを実現するためには、まずそれが必要なのかなと思っています。

ー 具体的に日頃意識していることはありますか?

自分にできないことが沢山あるということはわかってますが、最初から「できない」と諦めないようにしています。そういう意識を持っていないと、自分の弱さに負けてしまうので(笑)やってみてわかることもあるし、わからないところは教えてもらいながら進めています。

ー 意識していたからこそできた成長は何かありますか?

色々できるようにはなったと思っています。例えばバックエンドやフロントエンド、データ基盤など、好き嫌いなく触った結果、色々な技術を学ぶことができています。技術はもちろんだけど、周りを巻き込みながら自分主導で進めていく、ということにもチャレンジしていっています。

ー 新卒4年目で感じる自身の成長は何かありますか?

1年目2年目はわからないことだらけだったし、一緒にやってくれる先輩がいたからこそというのがあったのですが、今は「自分で進めていく」という気持ちが強くなってきているのかなと思っています。まずは技術を身につけ目の前を頑張ることが中心でしたが、徐々に全体を見れるようになってきたり、視野が広くなったと思っています。それがわかるようになってきて、チームのことやプロダクトのこと、どういうプロセスで開発していくのか、などの話に目を向けられるようになってきたと感じています。

でもまだ、臆病になっている自分もいたり、やってみたけれどうまく話を進められなかったり、関係者を巻き込めなかったりということもあるのでまだまだです。これからも部署や組織にとらわれず、改善したいという気持ちを前に出して頑張っていきたいと思っています!

最後にひとこと

教育という大きなテーマの中で、先生や生徒の役に立つものをつくること、それは難しいことであり面白いことでもあると思っています。Classiの仲間たちと力を合わせながら、より良いプロダクトを作っていきたいと思います。


新卒入社してこれからの成長も楽しみな小川さんでした。次回もお楽しみに!

Classiでは「子どもの無限の可能性を解き放ち、学びの形を進化させる」というミッションに、ともに向き合っていただける仲間を募集しています。ご興味をお持ちの方は、 お気軽にお問い合わせください!

■採用ページ

corp.classi.jp

■各ポジションごとの求人

*1:Classiではプロダクトで収益を上げているため、最終的にプロダクトのデリバリーまでを変えていかないとデータ関連業務の価値を発揮できません。そのため、キャリアの初めにエンジニアリングの素養を身につけることでデータ関連業務の幅を広げられるメリットがあると考えており、ソフトウェアエンジニアリングとデータサイエンスの両方をバランスよく学ぶことができる環境を提供し育成しています。

AWS GlueからAWS Batchにしたことで費用を75%削減した

こんにちは、最近データエンジニア業を多くやっているデータサイエンティストの白瀧です。

これまでClassiのデータ基盤は、Reverse ETLをしたり監視システムを導入したりとさまざまな進化をしてきました。しかし、Classiプロダクトが発展するとともにデータ量が増加し、これまでのデータ基盤では耐えられない状態に近づいてきました。

そこでデータ基盤の一部(DBからのExportを担う部分)のリアーキテクチャを実施したので、この記事で紹介したいと思います。

概要

Classiのデータ基盤では、Amazon RDSからAmazon S3へJSONで出力し、その後GCS→BigQueryという流れでデータを送り、BigQueryからもBIツールやReverse ETLなどで使っています。詳細は、Classiのデータ分析基盤であるソクラテスの紹介 - Classi開発者ブログを参照してください。

AWS Glueを使ってRDSからS3に出力していた基盤から、AWS BatchとスケジューラーにGoogle CloudのCloud Composerを使った基盤にリアーキテクチャしました。 主な変更点は以下になります。

  • 並行処理を管理しやすくするためにバッチ処理を実装する言語をPythonからGolangに変更した
  • CloneしたRDSに接続することで負荷増加によるサービス影響が出ないようにした

【リアーキテクチャ前】

Glue JobでS3にJSON形式でExportしてた
Glue JobでS3にJSON形式でExportしてた

【リアーキテクチャ後】

AWS Batch JobでRDSをCloneし、CloneしたRDSからJSON形式でS3にExportする
AWS Batch JobでRDSをCloneし、CloneしたRDSからJSON形式でS3にExportする

リアーキテクチャによる費用と処理時間の効果は以下のようになりました。

項目 効果
費用 およそ75%の削減
一連の実行完了までにかかる時間 6時間/日→2時間半/日
およそ60%時間削減
Jobのトータル実行時間 110時間/日 → 10時間/日
およそ90%時間削減

この記事で話すこと・話さないこと

この記事で話すこと

  • 構築したアーキテクチャについて
  • リアーキテクチャを実施することになった経緯と課題
  • 課題を改善するための技術選定と対応
  • リアーキテクチャによる効果

話さないこと

  • 各SaaS・クラウドに関する説明
  • 選ばなかった選択肢の詳細設計

課題

データ基盤のS3へのExport部分には下記のような課題がありました。

  • AWS Glueのメモリ不足によるJobエラーが時々あった
  • 1Jobあたりの実行時間が長いため、障害復旧に時間がかかり一度エラーが起きるとサービス影響が出てしまう
  • ローカルでの開発スピードが出ない構成で、アジリティが低い状態であった
  • サービスとRDS Clusterを共有していて、Export処理による負荷が高すぎることでサービスに影響が出たことがあった

これまで障害ごとに対応していましたが、データの規模と需要が大きくなるにつれてビジネスに影響が出ることが増えてきて根本解決する必要性が高まりました。

AWS Glueのままで上記の課題を改善しようと考えた時に、以下のような問題がAWS Glue(v1,2)特有で起きていました。

  • テーブルごとにAWS Glue JobとAWS Glue TriggerのTerraformリソースを作成していたため、Terraformの実行に時間がかかる
  • AWS Glue独自のリソース(Data Catalog, AWS Glue Connection)を使わないといけないため、ローカルでの開発スピードが出ない構成だった
  • データ基盤全体のスケジューラーがGlueのスケジューラーとCloud Composerのスケジューラーで分かれていた
    • スケジューラーが複数あり管理しにくかった
    • Glue側のスケジューリングが1テーブルごとに実行時間をずらす職人技になってしまっていた

私たちがAWS Glueをうまく使いこなせてなかった部分もありますが、AWS Glueの特徴であるマネージドな環境でのApache Spark実行とデータカタログとの連携など、どれも私たちが求めているものではありませんでした。

上記からAWS Glueを使い続ける意味もないので、アーキテクチャレベルで変えた方が良いと判断し、リアーキテクチャの方向で検討を始めました。

技術選定と対応

前提・制約

リアーキテクチャを検討する上での前提と制約について紹介します。

  • ClassiのRDSは学校ごとにDBが存在するマルチテナント構成
  • Reverse ETLの実施によるデータ基盤としての制約
    • 前日分のデータが朝8時時点で連携完了していること(つまりデイリー更新は必須でストリーミングは必要ない)
  • RDSから取り出す段階でマスキング処理をする必要がある

Reverse ETLについての詳細は、社内向けのデータ基盤から集計結果をReverse ETLしてサービスに組み込んだ話 - Classi開発者ブログを参照してください。

To beと開発方針

To be としては以下を満たすようなデータ基盤にしたいと考えました。

  • データ容量に対してスケーラブル
  • マルチテナントに対応したExport処理
  • 開発しやすい環境

上記を満たす選択肢の1つにModern Data Stackがありますが、社内の既存ルールとマルチテナント構成であることから採用は難しいと判断しました。

Exportを実行する基盤として、必要な要素ごとにAWS Glueで担っていた役割を以下のように分解しました。

  • アプリケーションを実行する基盤
  • スケジューラー + 依存関係管理
  • アプリケーション実装
    • 役割は、Connection管理、オーバーヘッド対処、メモリを抑える、テーブルスキーマの管理
  • RDS
    • サービスに影響出ないように分離する

それぞれについて技術選定と具体的な対応方法を紹介します。

アプリケーションを実行する基盤

アプリケーションを実行する基盤はAWS Batchを選択しましたが、その他にも以下の選択肢を検討しました。

  • ECS
  • Lambda

Lambdaは、リアーキテクチャ前にボトルネックとなっていたメモリ不足と時間制約から厳しいと判断しました。
ECSの特徴であるリクエストの即時性などは必要なく、ECSクラスタの構築が手間だったりとECSは全体的にオーバースペックと感じました。

上記に対してAWS Batchでは、名前の通りバッチ処理ワークロードに最適化しており、ジョブキューで優先度をつけられるため、Reverse ETLで使用するテーブルを優先的に連携することができることもメリットとして挙げられ、アプリケーション実行基盤はAWS Batchを採用しました。

スケジューラー + 依存関係管理

スケジューラーではGoogle CloudのCloud Composerを採用しました。 ここで検討した選択肢は以下でした。

  • Step Functions (AWS)
  • Cloud Composer (Google Cloud)
  • 自分たちで作る(AWS Lambda + なにかしらのDB)

改めてスケジューラーの役割を整理すると以下になります。

  • AWS Batch Jobを実行する
  • AWS Batch Jobのstatusを管理する
  • タスク間の依存関係を管理して、依存しているジョブが終了するまで実行を待つ

Step Functionsでは、依存関係が複雑になると実装も複雑になってしまうことを懸念しました。また、DBのデータを元に動的にAWS Batch Jobを生成する必要があり、難しいと判断しました。
Lambda + DBでこれを実現する設計・実装のコストが想定したよりも高くなりそうと見積りました。

Cloud Composerは後続であるS3→GCS→BigQueryの実行を管理しており、スケジューラーをデータ基盤全体で1つにまとめることのメリットが大きく、かつ年単位で運用してきたノウハウがあったことからCloud Composerを採用しました。

アプリケーション実装

このセクションでは、アプリケーションを実装する言語の技術選定とリアーキテクチャ前の課題を実装の工夫で解決したことについて紹介します。

パフォーマンスを向上させる

RDSの負荷が高かった原因はDBとのConnection数が多すぎたからでした。リアーキテクチャ前のアプリケーション実装言語であるPythonでConnection管理を実装をする方針もありましたが、Golangの方がConnectionを管理しやすいパッケージが存在し、かつgoroutineとchannelを使うことでメモリを圧迫しにくい実装が容易と判断しました。
さらに、Rubyの会社の手札にGoを加えるまで - Classi開発者ブログにあるように社内にGolangを書くことができるエンジニアがいるため、データエンジニア以外もこのシステムを開発できるというメリットもあり、アプリケーションを実装する言語はGolangを選択しました。

またJobの粒度も見直しました。リアーキテクチャ前は1Jobの単位がテーブルでマルチテナント横断の設計にしていたため、DB数×テーブル数分のDB接続切り替えが必要でしたが、1Jobの単位をDBにしたことで接続の切り替えが必要なく、オーバーヘッドが起こらない設計にしました。

Export対象のカラム変更を容易にした

Export処理を実行するために必要な情報は以下になります。

  • カラム定義に対応する構造体
  • マスキング対応を含めたSQL

これらの情報はDDLやActive RecordがDBマイグレーション時に生成・更新するスキーマファイルからgo generateで生成するようにしました。 これにより新規DB追加にも既存DBのスキーマ変更にも対応しやすいデータ基盤になりました。

RDS

サービスに影響を出さないために、RDS Cloneを実行しCloneされたRDSに接続するようにしました。 RDS Clone以外に検討した選択肢は以下がありました。

  • DB snapshot
  • binary logによる Replication
  • DB dump
  • S3にApache Parquet形式でExport

「S3にApache Parquet形式でExport」は実行時間が長すぎて実用的ではなく、「DB dump」はマスキングをするタイミングが要件を満たせないので選択肢から外しました。

残りの3つはそれぞれ検証の上、デイリー更新に十分な実行速度があり、かつ扱いやすかったのがRDS Cloneだったので決定打となりました。

リアーキテクチャの結果と効果

結果

リアーキテクチャ後の全体アーキテクチャ

処理の流れは以下のようになります。

  • RDSをCloneするAWS Batch Jobを実行
  • CloneされたRDSのデータをS3にExportするAWS Batch Jobを実行
  • CloneされたRDSをDeleteするJobをAWS Batchで実行
  • 後続タスク(S3からBigQueryへの連携など)を実行

効果

冒頭で紹介した内容と重複しますが、まとめて紹介します。

リアーキテクチャ前後で最も効果が見られたのは費用と実行時間です。

項目 効果
費用 およそ75%の削減
一連の実行完了までにかかる時間 6時間/日→2時間半/日
およそ60%時間削減
Jobのトータル実行時間 110時間/日 → 10時間/日
およそ90%時間削減

削減費用の内訳はAWS GlueとRDSで半々でした。

実行時間が短くなったことで、障害が起きた場合の再実行による復旧の速度が格段に速くなりました。 また、費用と実行時間がともに大幅に抑えられたことで、デイリーよりも早いサイクルでのデータ更新も現実的になりました。

上記以外にもリアーキテクチャによって得られた効果があったので紹介します。

開発体験がよくなった

気軽にstaging環境で実行することができるようになりました。AWS Batch Jobを単体で動かしたい時はAWSのコンソールから実行でき、一連の実行はCloud Composerでトリガーすることができます。開発体験の大幅な改善により、リリース速度が向上しました。

データパイプラインのモニタリングがしやすくなった

リアーキテクチャ前は、RDSからS3まではAWS Glue、S3からBigQueryまではGoogle CloudのCloud Composerが実行管理をしていたため、マルチクラウドかつそれぞれにスケジューラーが存在していました。そのためエラー時にどこでエラーになって止まってしまっていたのかを把握するためにデータ基盤監視システムを導入していました。データ基盤監視システムについての詳細は、データ基盤の品質向上への取り組み - Classi開発者ブログを参照してください。

リアーキテクチャ後では、全てCloud Composerで実行管理するようになったことで、どこでエラーが起きたかはCloud Composerを見ればわかるためモニタリングがしやすい環境になりました。

さいごに

今回のリアーキテクチャによってデータの規模に対してスケーラブルなデータ基盤になり、拡張性も増しました。さらに処理速度も大幅に改善されたことでデイリーより早いサイクルでのデータ更新も現実的になりました。

しかしユーザーへの価値に繋げるためにやることがまだまだたくさんあるので、この辺りを今後取り組んでいきたいと思います。

興味がある方は以下よりご応募をお待ちしております!

hrmos.co

© 2020 Classi Corp.