Next.js アプリケーションの共通コンポーネント開発

こんにちは。レシピサービス開発部のkaorun343です。クックパッドではスマートフォン向けページにおける開発者体験向上のために、レシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話にて紹介したとおり、Next.jsとGraphQLを用いたモダンな環境へと移行を進めています。例えばモバイル端末からのアクセスでURLがトップページの / であれば Rails、レシピ詳細ページの /recipe/:id であれば Next.js アプリにルーティングされるようになっています。現在ではレシピ詳細ページだけではなく検索結果ページやつくれぽ詳細ページ、MYフォルダページなどもNext.jsアプリケーションに置き換わっています。今回はその移行により生じた課題と取り組み方、それから併せて実施したモノレポ環境整備について紹介します。

共通コンポーネントの導入背景

cookpad.com では上述の通りページによってホストするアプリケーションが異なる一方で、ヘッダーやサイドメニュー、フッターといった全てのページに共通して表示されているコンポーネントがあります。このような、Next.js と Rails アプリの両方から利用される UI コンポーネントのことをこの記事では "共通コンポーネント" と呼ぶこととします。

クックパッドではハロウィンやクリスマスにあわせてヘッダーやフッターのデザインを変更する施策を定期的におこなっています。したがって共通コンポーネントに変更を加えたいとき、2つのアプリケーションのその両方の実装を編集しなければなりません。さらにこの2つのアプリケーションは使用している言語が異なるため、実装担当者にとってはさらなるコストになっています。 そこで実装を一度で終えられる仕組みを導入しました。

トップページとレシピ詳細ページのサイドメニュー(共通コンポーネントの例)

実装方法

結論としては、共通コンポーネントを以下の方針で実装することにしました。

  • Reactコンポーネントとして実装し、Next.jsでは通常のReactコンポーネントとして利用する。
  • 少ない労力でRails上で表示するために、上記のReactコンポーネントをウェブコンポーネントでラップする。

developer.mozilla.org

Reactによる共通コンポーネント作成

共通コンポーネントはReactで作ることにしました。この方針には2つ理由があります。

1つめは、Next.js上では今まで通り通常のReactコンポーネントとして使うためです。Vue.jsやSvelteなど他のパッケージを使わないため余計なコードが含まれなくなり、バンドルサイズが増加したり表示が遅くなったりする心配がありません。加えて、Next.js上で容易にSSRすることができます。

2つめは、Next.js アプリに実装済みのコンポーネントを共通コンポーネントとして再実装する際にその実装をほぼそのまま使えるためです。実際、Next.js用に実装したコードを少し修正するだけで済みました。

Storybookによる開発環境

共通コンポーネントの開発に際してはStorybookを導入しました。クックパッドではプルリクエストごとにステージング環境を作れる基盤があるので、このStorybookもプルリクエストで確認できるようにしました。これで変更内容をレビュアーが容易に確認できます。

また、表示の確認だけではなくテストにも使っています。Storybookの中で事前にpropsを渡したりcontextのproviderで包んだりしているので、テストの実装を綺麗にできます。

import { MyReactComponent } from './MyReactComponent'
import { MyProvider } from '~/contexts/MyContext'

export default {
  component: MyReactComponent,
  decorators: [
    (Story) => (
      <MyProvider>
        <Story />
      </MyProvider>
    )
  ],
}

export const Default = {}

export const Prop1 = {
  args: {
    prop1: 'prop-1',
  },
}
import { composeStory } from '@storybook/testing-react'
import { axe, toHaveNoViolations } from 'jest-axe'
import * as stories from './MyReactComponent.stories'

expect.extend(toHaveNoViolations)

const storyNames = ['Default', 'Prop1']

describe('MyReactComponent', () => {
  describe.each(storyNames)('%s', (storyName) => {
    const Story = composeStory(stories[storyName], stories.default)

    it('スナップショットテスト', () => {
      const { container } = render(<Story />)
      expect(container.firstChild).toMatchSnapshot()
    })

    it('アクセシビリティチェック', () => {
      const { container } = render(<Story />)
      expect(await axe(container)).toHaveNoViolations()
    })
  })

  describe('Prop1', () => {
    const Story = composeStory(stories.Prop1, stories.default)

    // 個別にテストしたいことを書く
  })
})

こちらの記事を参考にしました。 zenn.dev

リソース取得のためのAPIサーバー

Next.js版ではAPIサーバーとの通信に専用のGraphQLサーバーを使っています。共通コンポーネントの実装にあたっては、専用のGraphQLサーバーを用意するとサーバーの実装やCI/CD環境を構築する手間がかかることや、Next.jsのGraphQLサーバーと機能が重複していることから、Next.jsと同じGraphQLサーバーを利用することにしました。

ウェブコンポーネント作成

Rails アプリに直接 React コンポーネントをマウントしようとすると Rails アプリ側にたくさん JS のコードを書く必要があってつらいです。加えてRails側にReactコンポーネントを変換するための環境を構築しなければなりません。また、共通コンポーネントが内包するリセット用 CSS などをRailsアプリに影響させたくないといった課題がありました。 そこでRailsアプリケーションに導入する際は、Reactコンポーネントをウェブコンポーネントで包むことにしました。

ウェブコンポーネントは、再利用可能なカスタム要素を作成し、ウェブアプリの中で利用するための、一連のテクノロジーです。コードの他の部分から独立した、カプセル化された機能を使って実現します。 ウェブコンポーネント | MDN より

ウェブコンポーネントは複数の技術要素から成り立っています。カスタムエレメントが前者を、Shadow DOM が後者を解決してくれます。

カスタムエレメント

Reactコンポーネントをマウントする場合、自力でマウント対象の要素を探し、マウントしなければなりません。一方でカスタム要素にすることでそれを利用する側ではカスタム要素のタグを書くだけで済みます。

Shadow DOM

ReactコンポーネントをShadow DOMの子要素にマウントすることにしました。 共通コンポーネントはNext.jsアプリケーションと同じリセットCSSをベースにスタイリングしています。もしShadow DOMがなければRailsアプリケーションも同じリセットCSSを導入するか、共通コンポーネントのスタイリングそれぞれにリセットCSSを含めなければいけません。一方で Shadow DOMを使えばリセットCSSをShadow DOMの一番最初に追加するだけで済みます。

実装例

ウェブコンポーネント版の共通コンポーネントパッケージでは、Reactコンポーネントを表示するカスタムエレメントを提供します。

export class MyCustomElement extends HTMLElement {
  connectedCallback() {
    const mountPoint = document.createElement('div')
    // シャドウルートをカスタムエレメントに追加
    this.attatchShadow({ mode: 'closed' }).appendChild(mountPoint)
    // Reactコンポーネントのルートオブジェクトを作成
    const root = createRoot(mountPoint)
    const prop1 = this.getAttribute('prop1')

    // ウェブコンポーネント用のEmotionの設定
    const emotionCache = createCache({
      key: 'my-custom-element',
      container: mountPoint,
      prepend: true,
      speedy: true,
    })

    // Reactコンポーネントのレンダリング
    root.render(
      <CacheProvider value={emotionCache}>
        <GlobalStyles />
        <MyReactComponent prop1={prop1} />
      </CacheProvider>
    )
  }
}

共通コンポーネント(ウェブコンポーネント)を利用する側では、まずカスタムエレメントを定義します。そしてHTMLにそのカスタムエレメントのタグ名を記述します。

import { MyCustomElement } from '@cookpad/shared-components'

customElements.define('my-custom-element', MyCustomElement)
<my-custom-element prop1="prop-1"></my-custom-element>

モノレポ環境整備

モノレポ環境整備を整備する前の状況

共通コンポーネントはNext.jsとGraphQLサーバーのリポジトリに存在します。開発当初はモノレポ環境にしていなかったため、それぞれのパッケージが独立していました。ゆえに共通コンポーネントをNext.jsで使うためには一度npmパッケージとして公開するか、もしくは yarn link 機能を使わなければならず手間がかかっていました。加えてCIジョブもそれぞれのパッケージごとに作っていたので、今後新たにパッケージを増やすとそのたびにCIジョブも作らなければいけませんでした。そこで共通コンポーネントの実装と並行して、モノレポ環境を整備していきました。

Yarn Workspacesの導入

ワークスペース機能を使って共通コンポーネントを直接参照できるようにしました。Next.jsに共通コンポーネントパッケージをインストールするときはYarn workspacesを利用し、別リポジトリにあるRailsにインストールするときは社内のプライベートnpmレジストリに公開してから使っています。

yarnpkg.com

Turborepoの導入

Turborepoはモノレポ管理ツールと呼ばれるものです。設定を比較的簡単に書けるのが特徴です。また他のモノレポでは複数の実行環境に対応しているものもありますが、TurborepoはNode.js用の管理ツールです。対象のリポジトリはJavaScript(Node.js)だけからなるモノレポなので、Turborepoを採用することにしました。 Turborepoを導入することで、CIのジョブを1つにまとめることができました。また、変更があったパッケージとそれに依存するパッケージのみ必要なタスクを実行してくれるので、ジョブの実行時間が短くなりました。例えば共通コンポーネントパッケージに変更があった場合は、Next.jsのパッケージもテストなどを実行してくれる一方でGraphQLサーバーについてはタスクの実行をスキップしてくれます。

turbo.build

さいごに

今回はレシピサービスの共通コンポーネント導入とモノレポ環境整備について紹介しました。クックパッドではこれからもモダンな技術によるレシピサービスの刷新を引き続き進めていきます。この取り組みを一緒に進めてくれる仲間を募集していますので、興味のある方はぜひご連絡ください。

cookpad.careers

施策を "Own it" するエンジニア 〜オーナーエンジニア制度の紹介〜

レシピサービス開発部の新井(@SpicyCoffee66)です。いろんなゲームが好きなのでどれも中途半端にしか上手くならないことに 10 年以上悩んでいます。

この記事では、クックパッドのレシピサービス開発に導入している "オーナーエンジニア" と呼ばれる制度について紹介します。

  • クックパッドでサービス開発をしているエンジニアがどういう働き方をしているのか知りたい
  • ディレクターやデザイナーと上手く協業する方法が知りたい
  • サービス開発エンジニアはやることが多すぎてどうやって仕事をすればいいか悩んでいる

といったような方の参考になると思いますので、興味があれば是非ご一読ください。

制度導入の背景

オーナーエンジニアという役割について述べる前に、まずはレシピサービスの開発を取り巻く環境について軽く解説します。
現在クックパッドのレシピサービスは、エンジニアが 10 名と少し、デザイナーが 5 名程度、ディレクター数名の、計 20 名少しのメンバーで機能開発をしています。 開発手法にはスクラムを活用していますが、組織に合うようなチューニングを進めた結果、現在は企画や施策立案を中心に取り組む Discovery Process と、その実装とリリースに取り組む Delivery Process に分かれて運用しています。 前者はプロダクトバックログ(以下 PBL)にアイテムを追加していく役割*1を、後者はそのアイテムをスプリントバックログ(いわゆるカンバン)に落とし込んで実現していく役割を担っています*2

レシピサービスの開発におけるスクラムの全体像
レシピサービスの開発におけるスクラムの全体像

スクラムの導入自体は、開発組織の持つ課題を解決するために昨年導入されました。それにより、当時抱えていた課題は概ね解消されたと言えますが、新たに以下のような問題が発生するようになりました。

  • PBL のアイテムを数時間程度のタスクに分解しようとしたときに、要件や仕様に生煮えの部分が多く分解することができなかった
  • ディレクターやデザイナーがエンジニアにラフな相談をしたいときのやり方がわからなくなった
    • スクラム導入以前は施策ごとに担当エンジニアがつくようなやり方で開発を進めていたチームが多く、良くも悪くも個々人のコミュニケーションが多かった
  • 企画側から開発の様子が見えづらく、進捗の遅れや意図のズレに気がつくのが遅れてしまった

改めて並べてみると、開発体制の変化によってディレクター・デザイナーとエンジニア間のコミュニケーションに課題が生まれ始めていたことがわかります。この課題を解決するために生まれたのが「オーナーエンジニア」と呼ばれる制度です*3

オーナーエンジニアの役割

概要

オーナーエンジニアは PBL の各アイテムごとに担当エンジニアがつく制度で、主に以下の役割を担っています。

  • 施策の技術仕様を現実的なものに落し込む
  • スプリントプランニングでアイテムが見積もりできる状態に持っていく
  • 施策の評価指標の設計が必ず行われるように提案・助言する

この他にも、アイテムに関しての技術的な相談窓口だったり、アイディアの壁打ち相手などをやることが多いです。
役割としては技術領域の責任者なのですが、言葉の意味としては「担当施策のオーナーシップを持つエンジニア」という解釈の方が正確で、実装を中心にしながらも施策が滞りなく進行することに自体に責任を持っているという認識です。そのため、求められる知識や能力は幅広く、例えば以下のようなものが挙げられます。

  • 施策コンセプトへの理解
  • 施策理解をベースにした、実現方法の策定
  • 開発のリード
  • プロジェクト進行への理解
  • 何かあったら自分がなんとかするという気概*4

要素を並べただけではイメージしづらいと思うので、ここからは自分が実際にオーナーエンジニアを担当したときにおこなったことを紹介していきます。

実例

1. オーナーエンジニアのアサインとキックオフ

バックログリファインメントにおいて、優先順位が上がってきたアイテムにはオーナーエンジニアがアサインされます。今回自分にアサインされたアイテムは「つくれぽを投稿することによって自分がつくりたいレシピに出会いやすくなる体験をつくる」といった、規模が大きく抽象度も高いものでした。叩きのアイデアはあるものの、ここから仕様を取捨選択し磨き込む必要があるフェーズのアイテムだったため、ある程度長期間のプロジェクトになることを見越してキックオフの設定を提案しました。
キックオフでは、施策の目的やコンセプト・手法の概要など現時点で決まっていることを施策オーナーからインプットしてもらい、今後の進め方と次のアクションを決定します。今回は、前述したようにある程度長期間の伴走が必要になるアイテムだと感じたため、一時的なプロジェクトと考えて定例ミーティングも設定してその場は解散となりました。

余談ですが、自分はキックオフミーティングに必要な要素を以下のようなモデルで捉えています。このモデル自体は組織や個人によって変わってくると思いますが、こういうイメージを頭の中に持っておくと、自分で設定する時は抜け漏れが減り、キックオフに招待されたときにも足りない部分をフォローすること等ができて便利です。

キックオフの概念モデル
キックオフの概念モデル

2. 施策コンセプトの理解

キックオフで聞いた概要をもとに、施策の目的やコンセプトを自分の頭に叩き込んでいきます。開発のフェーズになると他のエンジニアメンバーとコミュニケーションを取ることも増えるため、この施策ではどういう価値を提供したいのか、あるいは検証したいのか、部署の目標とはどう繋がっているのか、その結果どういう手法を取るつもりなのかといったことを自分の言葉で話せるようにしておきます。
具体的には施策オーナーやデザイナーへのヒアリングをしたり、仕様やデザイン案を読み込んだり、ユーザーインタビューの録画を見たりしました。この部分の解像度が低いと、後の仕様を詰めていく工程で削るべき部分や残すべき部分の判断がつかなかったり、設計工程において適切な判断ができなかったりするので、しっかりと労力を割きました。

3. 仕様の相談と決定

施策のコンセプトを理解した上で、ディレクターの仕様やデザイナーのデザインに対して改めて確認や提案をしていきます。最初に出てきている案は検証したい価値に対して機能過多になる傾向があります。そのため、基本的には自分の中で MVP になる体験をイメージしつつ、削れそうな仕様を探したりフェーズ分割できそうなポイントを探したりしながら、仕様をコンパクトにできないか提案することが多いです。

こういう開発はなるべく避けたい
こういう開発はなるべく避けたい

加えて、リリース後にどういう分析をしたいのかを確認し、提案を交えながら埋め込んでおく必要がありそうなログをリストアップしておきます。後で「あ〜〜〜!ログ埋まってなかった!!!」というのはやりがちなミスなので、この時点でケアできていることが望ましいです*5

やりがちな失敗
やりがちな失敗

また、施策オーナーは仕様の技術的な難易度を認識していないことが多いため、その辺りもすり合わせながら整理していきます。実装難易度が高くなりそうな箇所については、改めて重要性の確認をしたり、ザックリの工数感と代替案を提示した上で再度意思決定をお願いしたりします。逆に、それほど工数がかからないような詳細の磨き込み等は、こちらから仕様追加を提案することもあります。
他にも、施策オーナーの希望しているスケジュール感を確認し、現状開発チームが出せているベロシティによっては、PBL 上での優先順位を上げてもらうよう PO とコミュニケーションを取ってもらうように提案したりもします。開発チームの現状はエンジニアからの方がよく見えるので、プロジェクト進行面でのフォローも効果的です。

ここで心掛けているのは、あくまで施策のゴールを達成するために仕様を煮詰めていくという意識です。仕様を削れば削るほど実装は楽になりますが、それ自体が目的になってしまっては必要な仕様まで削ってしまうことになりかねません。エンジニア目線だけを持つのではなく、施策のゴール達成という目標を同じ目線で見るプロダクト開発者として考えることが重要です。そのためにも 2 のフェーズで施策に対する解像度を高めておくことが重要になります。

4. 概算見積もり

ある程度固まってきた仕様をもとに概算で工数を見積もっていきます。設計方針を決め、コード調査をし、場合によっては他のエンジニアに相談しながら進めていきます。
施策の目的が検証であればデーターソースは YAML でよいと割り切れる*6が、恒常的な機能を開発するなら DB に table をつくる。施策の確度が低く、リリース後もどんどん改善していく想定であれば後から消しやすいように別の table でデータを持つが、そうでないなら今ある table に column を追加する。今後想定している利用者数の推移に基づいてスケーラビリティをどの程度考慮するか決めるなどなど……。このフェーズでも施策理解の解像度に左右される意思決定が多数存在します。繰り返しになりますが、施策意図のインプットが非常に重要です。

参考までに、自分はデザイナーが Figma で描いてくれたデザインをコピーしてきて画面遷移図をつくり、その上に付箋をペタペタする形で見積もりをつくっていくことが多いです。こうすることで、必要実装の抜け漏れが減りますし、視覚的にどういう機能をつくればいいかがわかりやすくなります。Figma 上で仕様やデザインについての質問が完結するのも便利です。

Figma での見積もり
Figma での見積もり

一人でグッと考えていると行き詰まることも多いので、なるべくラフに他のエンジニアに相談するように心掛けています。実際に開発に入る前の段階からエンジニアメンバーに施策概要が浸透することにもつながるので、一人で抱え込むよりは早めに状況を開示することが大事だと考えています。今のチームは #recipe-sekkei-inquiry という slack チャンネルを運用しており、割と雑な質問や相談が飛び交っているのがいい環境だなと思います*7
最終的にはエンジニアが集まる「概算見積もり会」という会議体に持ち込み、参加者から「ざっくりよさそう」の合意が取れれば、優先順位の高いものから順にスプリントに乗っていくことになります。

5. 開発

ここから先は基本的には一般的なスクラムの進行になります。スプリントプランニングでアイテムの詳細見積もりをし、タスクに書き下してスプリントバックログに貼る。デイリースクラムで進捗を確認しながら、メンバーが各々取り組みたいタスクを選んで受け持っていく。といった流れです。
ただし、少し規模の大きな開発になる場合は、最初に「設計ドキュメントを書く」というチケットを作成し、エンジニアメンバー間で認識をすり合わせるためのドキュメントを執筆します*8。設計ドキュメントのフォーマットは現状規定されていませんが、issue へのリンク、最低限の仕様、デザインのスクリーンショット、レスポンスの形式、実装予定のサービス名や該当ファイルなどが含まれていることが多いです。このチケットは原則としてオーナーエンジニアが取ることが多く、なるべくスプリントの序盤に取り組むことが推奨されています。

設計ドキュメントのイメージ
設計ドキュメントのイメージ

開発が始まってからは、他のメンバーが実装してくれた箇所については、積極的にコードレビューに入るなどして、仕様漏れのキャッチや整合性の担保ができるように心掛けます。

6. 分析・評価

機能をリリースした後は、分析・評価をおこなって next action を決定するまでが施策です。といっても弊チームではエンジニアではないメンバーが平気な顔をして分析 SQL を書いたりするので、3 の工程で正しくログを設計できていればあまりやることはないのですが。最近では、機能リリースの目処が立った時点で「数字を見る会」なる会議体が設定され、そこで関わったメンバーみんなが数字を見ながらやいのやいの言いつつ next action が決まるケースが増えてきました。分析・評価が実施されることや、その透明性を担保するための一つのプラクティスになりそうです。

雑感

本記事では、クックパッドのレシピサービスで導入されているオーナーエンジニアという制度について紹介しました。
改めて並べてみるとなかなかやることが多く「これは本当にエンジニアの仕事なのか?」と感じる業務まで含まれているようにも見えます。しかし「専門職が集まって分業する」というのは、得てして下図のように、イメージと現実の間にギャップがあるものです。我々がユーザーへの価値提供にフォーカスする限りは、現実の中で境界にあるような仕事を誰かが拾って進めていく必要があり、そのためにもできることは多い方がいいでしょう*9

分業のイメージと現実
分業のイメージと現実

もちろん自分だって凄腕デザイナーに CSS の修正をしてもらったり、視野広ディレクターに考慮漏れを拾ってもらったりしたことが数多くあります。僕は、専門性に軸足を置きながらも役割にとらわれないメンバーが多いチームは強いと信じているので、自分もそうありたいと思います。

また、今年自分でやってみて思いましたが、シンプルにオーナーエンジニアの仕事は楽しかったです。僕は「サービス開発エンジニアになりたい!」と言いながら 2017 年にクックパッドに入社し、エンジニアをやったり PjM をやったり部長をやったりしていましたが、どれもエンジニアリングとサービス開発に対して同時に向き合うことは難しい仕事でした。今年それらの経験を経た上でエンジニアに戻り、オーナーエンジニアをやってみたところ

  • プロダクト開発と技術力の両面を同時に求められ、向き合う必要がある
  • それぞれの能力が施策のクオリティに直結する実感がある
  • その上で、エンジニアリングという専門性に軸足を置いているので(キャリア的な意味で)自我を保ちやすい

という実感がありました。サービス開発にはさまざまな要素が存在するため、日々の仕事の中で自分の役割に迷う方も多いのではないかと思います。そんな方の参考になれば幸いです。

この記事の内容について質問などある方は、気軽に Twitter などにご連絡ください。選考応募はもちろんのこと、カジュアル面談も積極募集しているため、チャネルにこだわらずお声がけいただければと思います。

*1:特定の人だけが PBL にアイテムを追加できるというルールではなく、あくまでメインの役割として担っているメンバーです。実際に、たとえば技術的負債解消のためのアイテムが Delivery Process のメンバーから起票されるようなこともあります。

*2:あくまで同じチームではあるため、レトロスペクティブ等は合同で開催しています。

*3:スクラム導入以前に発生していた課題については Cookpad TechConf 2022 で発表された「レシピサービスにおける持続的なプロダクト開発プロセスについて」というセッションで、Discovery Process と Delivery Process やオーナーエンジニアについては Cookpad Lounge #15 でも触れられているので、よろしければ併せてご覧ください。

*4:弊社 Values の一つ "Own it." の精神です。

*5:この辺りの考え方は、自分がリーンスタートアップをもとにプロダクト開発をしている影響もありそうです。過去には MVP に触れている記事も投稿しているので、よろしければ参照ください → https://techlife.cookpad.com/entry/2018/02/10/150709

*6:極端な例として挙げているものの多くの場合はよくない

*7:この辺りの話は Kaigi on Rails 2022 で弊チーム Techlead の akamatsu が話していたので、興味のある方は併せてどうぞ → https://kaigionrails.org/2022/talks/ukstudio/

*8:こちらも前述した akamatsu の発表で触れられています

*9:弊社エンジニアリングマニフェストにある "境界を越える" の精神です。

Rubyインタプリタの品質向上のために個人的にやっていること

技術部の笹田です。Ruby 3.2 無事にリリースされて良かったよかった。

Rubyインタプリタは複雑なプログラムなので、当然のごとくバグが入ってきます。Rubyインタプリタ開発者は、これに対していろんな対策をしています。たとえば、テストを書いて、CI環境でチェックするとか、今となっては当然のことを、当然のごとくやっています(RubyCIchkbuildruby/spec: The Ruby Spec Suite aka ruby/specなどの整備や、実行環境の日々のメンテナンスの成果です)。

これに追加して、個人的にテストをとにかくたくさん繰り返し行うマシン群を用意しています。テストの実行頻度をなるべくあげて、「時々しか発生しない」というバグを炙り出して、Rubyインタプリタの品質向上を目指すためです。本稿ではそんな、ちょっとだけ変わったテスト環境についての話をご紹介します。

このテスト環境を用意するために、いろいろな方にご支援いただいております。本稿では感謝の意をこめて、そのご支援をご紹介させていただきます。

バグを「炙り出す」必要性

定期的にテストを実行する環境

よくある CI/CD の文脈では、リポジトリへのコミット(PR)単位でテストを実行します。もし問題がでたら、そのコミットに問題があることがわかるためです。GitHub Actions などでよく対象にするのはそんなテストです。

つまり、

「バグは修正に混入する → 修正ごとにテストを走らせることで、そのバグを見つけることができる」

という仮説のもとに定期的にテストする環境を用意するわけです。

この問題に対処するため、Ruby インタプリタ開発では、次のようなテスト環境を用意して利用しています。

  1. GitHub Actions による PR 単位、push 単位でのテスト環境
  2. chkbuild による網羅的なテスト環境(rubyci

1 も 2 も、基本的には直前に入った修正に問題がないか、チェックするための仕組みです。

2 は、いろいろな OS などの環境で、毎回 clean build して逐次テストすることで、正確なテスト結果を出します。ただ、時間がかかるため、2時間に1度程度、実行されています。

現在は、計算機の多くは一般社団法人 Ruby Association などからのご支援を受けて AWS 環境に構築しています。また、GitHub actions は GitHub 様から計算機資源の提供をいただいています。

そういえば、Shopify では、彼らの(おそらく膨大な)アプリケーションのテストをRubyの開発版で行っていただいているそうです。助かりますね。

ときどき落ちるテストを発見するテスト環境

Rubyインタプリタくらいの規模のソフトウェアになると、何も変わらないのに、時々落ちる、という現象にあたることがあります。また、修正はあっても、その修正では考えられない理由でテストが落ちる、ということもあります。こういうのを flaky test などということがあります。これには、いくつか理由が考えられます。

  1. テストが悪い
  2. 「時間」や「システムの状況」など外部要因に起因するテスト
  3. すでにバグは混入しているが、運が悪い(良い)ときにしか見つからない

経験上一番多いのは 1 のテストが悪いというものです。たとえば、テストするメソッドの順番に依存していたりすると、何かの拍子に問題が生じることになります。タイミングがシビアなテストを書いていると、ちょっとタイミングがずれて時々失敗する、みたいなこともあります(マシンスペックが変わって失敗する、とかもありますね)。

2 の外部要因に起因するテストも、テストが悪い、と言えなくもないですが、時々あります。たとえば ruby/zlibのテストが何もしていないのに失敗するようになった話 - @znz blog で紹介されている例は、特定の時刻でタイムスタンプが特定のデータを生成してしまい、テストが失敗してしまう、というものでした(テストを修正して解決)。

まぁ、上記は「テストが悪い」の範疇なので、インタプリタ自体の品質には直接関係ありません。ただ、これらを放置するとテスト結果を確認するのが億劫になるので、出来るかぎり早く修正する必要があります。われ窓理論ですね。

で、3の運が悪い(良い)と現れる問題が、インタプリタの品質にとって大切になります。1万回に1度、運が悪いと出現するようなバグでも、1日に利用者が1万人いるソフトウェアだと、1日に1度は踏んでしまうかもしれません。というか、踏んじゃいます。さらに悪いと、脆弱性のもとになってしまうかもしれません。

この手のバグが出やすいのは次のような場面です。

  • 自動メモリ管理(GC)
  • キャッシュを用いているもの
  • 並行・並列実行をしているもの
  • ネットワークなど、外部のシステムを利用しているもの

どれも、非決定的、つまり2度実行しても同じ結果にならないような挙動を持ち込みやすい部分です(そして私が良く扱う分野です)。ほかにも、システムによるメモリアドレスのランダマイズなど、「あれ、さっきと結果が違うぞ?」という状況を作る原因はいろいろあります。

で、いろんな工夫が考えられるのですが、われわれは「とにかく数を実行してみる」という手法を用いています。単純ですね。1万回に1度出るなら、1万回動かせば再現するだろう、という話です。

つまり、

「あまり出現しないバグがすでに混入している → テストの試行回数を増やせば、このようなバグを踏む確率が高くなる(炙り出せる)」

という仮説のもとに、とにかくたくさんテストを実行するテスト環境があるといいなぁと思うわけです。

先ほどご紹介した chkbuild では一日に 12 回程度(これに環境の数だけ掛け算)、GitHub actions ではイベントごと、ということで、「沢山実行する」というにはちょっと足りません。そこで、独自にテスト環境を作って5年くらい運用しています。

もともとは GC 開発時のデバッグで「時々起こる」バグに業を煮やし、1台でずっとテストを走らせていたことから始めました。

始めた当時は、while make up all test-all; do date; done というコマンドで無限にテストを走らせました(失敗したら止まります)。ただ、これだと結果を確認するためにターミナルを見なければならず、また意図しないところで停止ししていると気付くことができません。また、スケールも難しいので、環境一式を作りこんでいった感じです。

たくさんテストを実行するための工夫

テストをたくさん実行するためには、次のような工夫を行いました。

  • マシンを複数台使う(スケールアウト)
  • 性能の良いマシンを使う(スケールアップ)
  • 1マシンで複数のテストを同時に実行してハードウェアリソースを使い切る
  • 1回のビルド・テストの試行時間を短くする

それぞれご紹介します。

利用するマシンの用意

お金があればクラウドでマシンを沢山用意してスケールアウトするのが一番確実(そして、慣れた人には簡単)なのですが、個人で行っている活動なので、用意できる金額に限りがあります。また、この手の計算機リソースを使い切る用途は、安いクラウドサービスにはあわないというものがあります。

自宅のスペースに若干の余裕があったので、現在は実マシンをてきとうにおいて運用しています(子どもたちが大きくなると余裕はなくなるため、この活動もそこで終了しそう)。AWS などの料金表をにらめっこしてみたのですが、やはり実マシンが一番安いですね...(割引プランをいろいろ探せばもっと安いんだろうか)。10万円弱で 8 cores 16 threads のちょっとした良いマシンが買えるのでありがたいです。現在は、4台のマシンで運用しています。

我が家においてあるマシン群

新しいマシンはどれも小さいです。以前はミドルタワーのマシンを並べていたんですが、さすがにむっちゃ邪魔で...。HX90 は先日のブラックフライデーでちょっと安かったので買ってしまいました。

テストの実行時間はCPUの動作周波数にきれいに相関していました。速ければはやいほど良い。

メモリは1つのテストスイートを走らせる程度なら、ビルドや各テストを並列実行しても2GB程度あれば十分なようで、意外にもそんなに必要ありませんでした。

電力計をつけているのですが、見ていると全部で 400Wh のあたりを上下している感じです。東京電力の料金 スタンダードプラン(関東)|電気料金プラン|東京電力エナジーパートナー株式会社 を見ると、301kWh を超えると 30.57円/1kWh のようですので、この数字をもとに計算してみると 30.57円/kWh * 0.4kWh * 24h * 30d = 約 8804 円。まぁ1万円弱くらい。こちらも GitHub sponsors の収益で一部補填させていただいております。

(ちなみに、この電気代には先ほど紹介した rubyci/chkbuild で利用している Mac mini 3台が入っています。Mac mini は一般社団法人日本Rubyの会のご支援で購入したものです)

今は寒いからいいんですが、暑い時期は(エアコンを入れなかったので)ファンがすごい音をたてていました。火事が心配。今のところ、連続稼働でも1年以上は動いています。ただ、5年たったらミドルタワーのマシン2台が壊れました。小さいマシンはもっと寿命短そう。

マシン代は(古いのはおいといて)22万円で3年で減価償却するとして7万円/年くらい。電気代は大雑把に12万円/年。つまり20万円/年くらいでしょうか。場所代とメンテ人件費が要らないのでやっぱり安いですね。落ちたら大変、ってシステムもないので、SLA も要らない。まぁ、家でマシン並べるのは趣味ですよねぇ。

余談ですが、物理マシンを手元においているのはベンチマークをとるため、という側面もあります。クラウド上のマシンだと、インスタンスガチャみたいな話もあるので、なるべく物理マシンを利用したいというところです。例えば https://rubybench.github.io/ のマシンは我が家でホストしているマシンになります(このマシンも日本Rubyの会様にご提供いただきました、ありがとうございます)。新しい機能のベンチマークを真面目にとらないといけないときは、動かしているテストをとめてベンチマークしたりしています(ベンチマークのために複数台必要になることがあるためです)。

ビルド・テストプロセスの並列実行

テストの回数を増やすために、テストスイートを実行するプロセスを1つのマシン上で複数起動する、という方法があります。

テストスイートを実行すると、リソースを消費するときと暇なときがあるので、あるテスト実行プロセスが暇なときに別のテスト実行プロセスを走らせることで全体のパフォーマンス向上を目指すという考え方です。ただ、同時実行テストプロセス数が多すぎるとリソースの取り合いにリソースが消費されてしまうため、全体のパフォーマンスは悪化する危険があります。

単純にテストプロセスを複数実行すると、テスト同士で干渉することがあったので(たとえば、ファイルシステムやネットワークのポート)、いろいろ試行錯誤しながら Docker コンテナで設定をいくつかいじれば大丈夫であると突き止めました。今は 1つのマシン上で 22 の Docker コンテナがそれぞれ同時にテストスイートを実行するようにしています(build-ruby/run_sp2.rb at master ・ ko1/build-ruby )。メモリは 32GB で何とか足りています(ただし、後述する RAM ディスクはあきらめました)。

Dockerコンテナでいろんなテストを同時実行している様子(メモリ消費)

ビルド・テスト時間の短縮

最新版のRubyをビルドしてテストスイートすべてを走り終わるまでの時間を短縮するため、次のような工夫をしています。

  • コンパイル結果などを再利用する
  • RAMディスクを用いる
  • ビルド・テストを並行処理する

rubyci.org に掲載されているテスト実行は、テストの結果を確実にするため、一切のコンパイル結果などの再利用をしません。ただ、今回は数を稼ぐことが目標なので、コンパイル結果を積極的に再利用するようにしています。ただし、再利用を起因とする問題もたまにあるので、2度連続で失敗したときは、一度コンパイル結果などをすべて消し、まっさらな状態からビルドするようにしています。

メモリが比較的余っている環境では、ビルド結果はすべてRAM ディスク(tmpfs)を用いて、ちょっとでもビルドが速くなるようにしています。ただ、これどれくらい効くかは微妙です。性能に関連しそうなデータは、OSが勝手にメモリ上にキャッシュに載せたりするためです。なんとなく速いような気がする、という気持ちの問題みたいな側面が大きいです。

ビルドの並列実行は make -jN とするやつです。10年くらい前は結構これに起因するバグもあったんですが、今ではほぼ問題なく並列ビルドできています。

テストを並列に実行する、というのは、テストスイートを分割し、その結果を並列に実行するというものです。この環境で実行するRubyのテストは大雑把にわけて3グループあるのですが、そのうち1つが以前より並列処理に対応していました。数を稼ぐという目標のために、さらに1つのグループ(btest)を並列実行可能にするように書き換えました。

これらの工夫により、速いマシンを占有して「最新版をビルド→テストの実行」を繰り返し実行している環境では、「最新版をビルド→テストの実行」が2分弱程度で終わることができるようになっています。つまり、常にリポジトリから最新版の Ruby を取得しテストするため、テストが通らなくなるような問題のあるコミットをすると、はやいと2分程度でテストの失敗通知が得られるようになっています(結果は Slack に通知される)。

テストを繰り返している様子

テスト回数

これらの工夫により、1日に2000回程度のビルド・テスト実行ができるようになっています。5日で1万回。

バグを炙り出す工夫

テストを増やす

バグが混入されているにしても、そのバグを絶対踏まないコードしかなければ、そのバグを検出することはできません。そのため、広範なテストが必要になります。すでに Ruby は大きなテストセットをもっているため、それを利用しています。

また、Rubyインタプリタのソースコードには、たくさんのアサーション(プログラムのこの箇所では必ずこうなっているだろう、という状態の表明)が入っています。これも、テストの一種と考えられるでしょう。自分がコーディングする部分では、このようなアサーションを増やすことで、おかしな状態を検出できるようにしています。

これらのアサーションは多くはデバッグビルドでのみチェックが有効になります。そのため、走らせているいくつかの環境でデバッグビルドを用いて実行させています。

テストについて、理想的には、著名なアプリやライブラリを持ってきて、そのテストを最新の開発版 Ruby で動かすと良いと思うのですが、そこまで手が回っていません。

テストパターンを増やす

実行するテストはすべて一緒ではなく、さまざまなパターンでテストを走らせることでバグを炙り出そうとしています。

  • いろいろなパラメータでビルドした Ruby インタプリタでテストを実行。
  • ビルド環境(コンパイラ)のバージョンを変えてテストを実行。
  • テストの順番をランダムにして実行。たとえば、テストの実行順によってメソッドキャッシュの状況が変わるので、そこで発見できるバグがあるかもしれない。
  • テストを繰り返し実行。同じく、同じテストを繰り返し行うことで、メソッドキャッシュの状況が変わる可能性がある。

この辺を一括で記述することができるように、設定に従って Ruby をビルドし、テストを走らせるソフトウェアを書きました(ko1/build-ruby: Build Ruby from source code. )。設定一覧は例えばこんな感じ: https://github.com/ko1/build-ruby/blob/master/docker/ruby/targets.yaml

エラーへの対処

予期しない問題に対処するため、いくつか工夫しています。

  • 全実行ログの記録
  • 無限に停止するこことをふせぐためにタイムアウトを設定可能に
    • タイムアウトがあったら、gdb で関連プロセスのバックトレースをダンプ
  • core を吐くような異常終了時には core をダウンロードできるように
  • 失敗が続いたらデータを全部削除したり、実行間隔をあけたり

しかし、テストが失敗しても結局原因はわからないことも多いです。もう少し工夫したいところです。

結果を確認するためのシステムの整備

結果を集約するサイト ci.rvm.jp を作っています。見る人は限られているので、DB は SQLite3 という雑さ(なので遅い)。本当にヨワヨワサーバなので、リンクにもしていません。

失敗ページを見ると、何がまずいかわかりやすいように stderr への出力だけ実行結果の概要ページで見えるようにするなど、ちょっと工夫しています(が、世のCIサイトは無限にやってそうな話ですね)。

テストが失敗したら Slack での通知(Rubyコミッタの方々が見ているところ宛て)とメールでの通知(これは私にだけ宛て)が飛ぶようになっています。必ず失敗するようなコミットが稀に入ってしまうのですが、そのときは通知がひどいことになります。

余談:その他の考えられる工夫

非決定的な挙動をテストするためにはいろいろな手法が考えられます。

例えば、入出力やスレッドスケジューリングなど、外部のイベントをすべて決定的になるようにOSなどのレベルで整備するものです。つまり、いろんな工夫で同じプログラム(と外部からの入力)については必ず同じ結果を返すようにする、というものです。一度、問題を発見できたら、その問題が必ず再現できる、となればはかどりそうですね。ただ、研究レベルではいろいろ聞いたことがあるんですが、実際どれくらい実用になるんでしょうね。

形式手法を用いて網羅的なテストや、網羅しやすくするデータを自動的に生成する、といった手法も考えられます。こういうのできるとかっこいいですよね。

成果

毎日数千回の試行があると、けっこうバタバタ失敗するため、最初はかなり頑張って修正しました。主にテストの不備が多いので、だいぶ頑張って修正しました。

また、タイミングに起因するバグも修正することができました。手元のメモに残っているパッチだとこんなものがありました。

おわりに

本稿では、品質向上のために個人的に行っている「テストの実行回数を増やしてレアなバグを見つける」ための仕組みについてご紹介しました。

プログラムにはバグはつきものですし、大きく複雑なプログラムのバグを見つけるのは大変です。今回は、そんな試行錯誤の一端をご紹介しました。今回ご紹介したものは、とにかく力業、という感じなので、もう少し科学的アプローチもできればいいなぁ、と思っています。いい方法知っていたら教えてください。

記事中でも言及したとおり、この仕組みは多くのご支援を受けて実現しております(ご紹介できなかった方もいるかもしれないですが、ごめんなさい、感謝してます)。改めて御礼申し上げます。

マシンについては、数年前に某社から不要になったメモリ3桁GBのごついラックマウントマシンを3台いただきまして、それを別の某N社に設置させていただき、これらを含めて運用してきました(マシンの運用はN社のS様にずっと面倒見てもらっていました)。先日、これらのマシンがさすがに古かろうということで撤去されたので、その供養と感謝を込めてこの記事を執筆しました。どうもありがとうございました。

では、良いお年をお迎えください。

... 今年は「Ruby 3.2 の XXX 自慢したい」記事がないのですが、それについてはまた来年ご紹介します。