Classi開発者ブログ

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

英文法問題の制作プロセスに生成AIを取り入れた話

こんにちは。データサイエンティストの高木です。
弊社では昨年6月に「学習トレーニング」機能をリリースし、機能の利用促進や改善が進んでいます。
corp.classi.jp

このような学習機能を通して、より質の高い個別最適な学習を実現するためには、多くの「問題」が必要となります。
しかし、問題制作には膨大な時間と費用がかかってしまいます。

そこで、これらの制作時間や費用の削減を目的として、これまで制作工程の一部を自動化する試みを行ってきました。

tech.classi.jp

本記事では、2月にプレスリリースで公表された、学習トレーニングの英文法問題の制作プロセスに生成AIを取り入れた話について紹介します。

prtimes.jp

問題の制作工程

問題の制作工程は以下になります。

図1 問題の制作工程

まず、企画要件に合う問題を教材や過去に作成した問題から選定します(図1: 1)。
次に、それらを基に原稿を作成します(図1: 2)。このとき、全く同じ問題にならないように改題します。
もしも、過去の問題に企画要件に合う問題が存在しない場合は新規で作成します。

そして、作成された原稿をチェック・修正し(図1: 3-4)、社内のCMS(Contents Management System)へ搭載します(図1: 5)。
さらに、テスト画面上でチェック・修正し(図1: 6-7)、問題がなければ本番環境で公開されます。

今回、制作の対象とする英文法問題の例は以下になります。

図2 英文法問題の例

1つの問題は、問題文、選択肢(正答、誤答)、解説文から構成されており、英語の例文の空欄箇所に合う選択肢を1つ選ぶ問題となっています。
空欄箇所で問われる内容(選択肢の種類)について、今回の取り組みでは、英文法問題でよく出題される「動詞の種類」を問う問題を主に作成しています(図2の左の問題)。

また、文法項目に入れ込みながら、語彙力を強化していく目的で「語彙」を問う問題も数問作成しました(図2の右の問題)。

課題とアプローチ

制作工程の中で最も時間と費用がかかるのが、問題の土台を作る図1の1と2の工程です。
問題の制作者(以下、作問者)はいくつもの教材や過去の問題を目視でチェックし、手作業で問題を収集しなければなりません。

さらに、英文法の問題の場合、例文の正しさや長さ、英単語の語彙レベル(単語の難易度)や文脈の適切さ、などが求められます。
これらを考慮して収集した問題を改題したり、場合によっては新規作成しなければなりません。
英語や作問に関する知識があるプロの作問者でもかなり難しい作業です。

そこで、我々はこれらの選定や改題の作業の手間を省くために、作問者の要件(高校英語の単元、語彙レベル、選択肢の種類など)に合う英文法問題を自動生成し、一括で出力することが可能なアプリを構築しました。

具体的な手順の詳細は後述しますが、基本的には、予め、高校英語の単元ごとに出題されるであろう例文を大量に用意しておき、それらをベースに選択肢や解説文を生成するというものです。
これらの生成過程を分解し、GPTやBERT、その他自然言語処理技術を適用することで問題の生成を実現しています。

問題選定時(図1: 1)にこのアプリを利用することで、様々な教材から手作業で選定する時間が削減され、制作費用も削減されることが期待されます。

取り組みのねらい

今回の取り組みでは、社内検証にとどまらず自動生成した問題を実際にリリースすることを目指しました。理由としては以下です。

  • 今後の検討のために各工程に及ぼす効果のベースラインを作っておきたい
  • 制作からリリースを一気通貫することで見えてくる課題や効果がある
  • 技術検証のみで終えず、ユーザーである先生や生徒に届けたい

しかし、作問はとてもクリエイティブな作業であり、人間が行うのも難しい作業です。
この作業を自動化すること自体とても難易度が高いです。

そこで、これらの取り組みをリリースにつなげるために、問題制作の専門家であるコンテンツチームと、自然言語処理技術や教育工学的視点から見た作問についての知見を持っている大学と連携しながら進めてきました。

また、生成AIを使った前例を作っておきたかったというのも一つのねらいです。
今後これらの活用はさらに活発になっていくと思われ、社内でLT会を開いて活用アイデアを出し合ったこともありました。

tech.classi.jp

しかし、著作権など活用するうえで注意しなけれならない点も多々あります。
まだ前例がなかったので、そういった課題とどのように向き合い活用していけば良いかが不透明でした。

そこで、今回の取り組みで、ある程度これらの課題をクリアにし、今後の活用のしやすさに繋げられればと考えました。
実際に、今回の取り組みを機に、生成AIガイドラインが作成され著作権リスクをチェックするプロセスが作成されました。

図3 取り組みの全体像

英文法問題の自動生成

英文法問題の自動生成手順を以下に示します。

図4 英文法問題の自動生成手順

問題を生成する前の準備段階として、高校英語の単元や学習項目ごとに例文データを2つの方法で作成しておきます。
1つは、弊社で過去に出題された問題から例文を抽出する方法です(図4: ①)。
もう1つは、GPTで例文を生成する方法です(図4: ②)。

出題された問題から抽出した例文は、同じ例文にならないように言い換えたものを使用しています。
言い換えの例は以下です。

(変換前)Everyone in our class likes ms. brown.
(変換後)Ms. Brown is well-liked by everyone in our class.

この変換にはGPTを使用しており、以下のようなプロンプトで変換しています。

I'm going to show you some sentences, please rewrite them in different structure and keep their meaning, and try to avoid too high level words.
The rewritten sentences should be clean and natural, without any unnecessary characters.
If the sentences are likely to be in a dialog, you should keep them as a dialog.
Sentences in a dialog start with `-- `.
You should fix mistakes in the sentences if there is any.
You can ignore meaningless words if necessary.

一方で、GPTによる例文の生成では、以下のようなプロンプトで生成しました。

受動態の例文で、例)のような例文を5つ下さい。また、日本語訳も下さい。
例)This car was repaired by brother.

このように例文データを予め大量に作成しておきます。
そして、作問者が作成したい問題の学習項目や例文にマッチする例文を例文データから検索します(図4: ③)。
この検索では、例文間の係り受け構造(dependency tree)をSpacyで解析し、それらの構造の近さから類似度を判定しています(下図)。

図5 係り受け構造に基づく例文間の類似度判定

そして、問題で問われる空欄箇所を特定し選択肢を生成します(図4: ④)。
選択肢の生成では、BERTのbert-base-uncasedモデルを使用しています。

また、選択肢の語彙レベルを制御して出力することも可能となっています。
語彙レベルの制御には使用許諾を取ったうえでCEFR-Jのwordlistを使用しています。

最後に、GPTによって解説文を生成します(図4: ⑤)。
解説文を生成するプロンプトの例は以下になります。

次の日本文に合う英文になるように,( ) に適するものを一つ選びなさい。
{日本文}
{英文}
選択肢
{選択肢}

正答を教えてください。
また、次の順番で以下のキーワードを使って解説をしてください。
{キーワード}

この設問文の文型と意味を説明してください。

これらの手順を組み込んだアプリとその出力例が以下になります。

図6 英文法問題の自動作問アプリの入力画面

図7 自動作問アプリの出力例

リリースと検証

今回、受動態の英文法問題21問を自動生成し、それらの問題が学習トレーニング上でリリースされました(図2)。
実際の作業では、まず、64問を自動作問アプリで生成しその中から21問を選定しました。
その後の作業は、図1の工程2以降の作業と同様です。

これらの作業をコンテンツチームの制作担当者に行ってもらい、自動生成された問題の質はどうだったか、制作時間や費用は削減されたのか、などを評価・検証しました。

自動生成された問題の質

自動生成された問題の質を評価するために、(1)問題内容のチェックによる評価(定性)と(2)問題内容の修正割合による評価(定量)を行いました。

(1)問題内容のチェックによる評価 

はじめに、作問の専門家であるコンテンツチームに問題の品質チェックで必要となる観点をヒアリングしました。
さらに、研究分野における問題作成ガイドライン*1などを参考にし、12個の評価項目を作成しました。

この評価項目を基に、リリースされた21問の問題について作問者に評価してもらいました。
各項目の「適切か」の基準は「修正を入れずにそのまま使えるかどうか」としました。
そして、各項目を満たしていた問題の割合を算出しました。

評価結果は以下になります。

表1 作問者による問題の評価

評価項目 評価項目を満たした問題数(問) 割合(%)
①学習項目に適した問題になっているか 20 95.2
②例文の語彙レベルは適切か 17 81.0
③例文の長さは適切か 21 100.0
④例文の文法は正しいか 18 85.7
⑤例文の文脈は適切か 13 61.9
⑥例文のブランク箇所は適切か 20 95.2
⑦選択肢の語彙レベルは適切か 19 90.5
⑧正解選択肢は唯一となっているか 20 95.2
⑨誤答選択肢はもっともらしいか 11 52.4
⑩解説文に正解となる理由が述べられているか 20 95.2
⑪解説文に誤答選択肢について説明がされているか 20 95.2
⑫解説文の内容は適切か 0 0.0
平均 16.6 79.0

12項目中9項目で80%を超えており、概ね良好な結果となりました。
例文の文脈や誤答選択肢の適切さが少し低い値となっています。
解説文については1つもそのまま使えるものがなかったという結果でした。

今回自動生成した解説文は、正答選択肢と誤答選択肢の説明をするように出力していましたが、実際には正答選択肢の説明のみのシンプルな解説文が必要となり修正が発生したため、このような結果となっています。

(2)問題内容の修正割合による評価

自動生成されてからリリースまでに修正された文字列の割合を算出し評価しました。 修正された文字列の割合 \text{edit_dist_rate} 0 \leqq \text{edit_dist_rate} \leqq 1を満たし、以下の式で計算されます。

 \displaystyle
\text{edit_dist_rate} = \frac{\text{Levenshtein distance(修正された内容, 自動作問の内容)}}{\text{Max(Len(修正された内容), Len(自動作問の内容))}}

計算結果を以下の表に示します。

表2 修正割合による評価

問題文 和訳 選択肢 解説文
 \text{edit_dist_rate}(平均) 0.24 0.21 0.06 0.86
 \text{edit_dist_rate}(中央値) 0.13 0.00 0.00 0.86

問題文、和訳、選択肢ともに中央値は低い値となっています。
(1)の評価結果から、文脈や誤答選択肢でそのまま使えないものはあったものの、修正された文字数としては少なかったと考えられます。

一方で、解説文は高い値となっています。
(1)の結果の通り、シンプルな解説文に修正されたためこのような結果になりました。
解説文を生成するプロンプトを変更することで改善されると考えられます。

自動作問アプリによる費用や時間の削減効果 

図1の各工程では、1問あたりにかかる費用と時間が設定されています。
今回の作業を通して、自動作問アプリを使った場合、各工程1問あたりにかかる費用がどのくらい削減されるかを制作担当者に見積もってもらいました。
そして、それらの費用の削減率から削減された時間を計算しました。

(1)制作時間

図1の工程1から4にかけて、自動作問を利用した場合、1問あたりの制作時間が24%削減されました。

また、工程6の画面チェックにおける修正件数を、自動作問アプリを使った場合と使わなかった場合で比較しました。
その結果、使わなかった場合の修正件数が73件だったのに対して、使った場合は7件に減少しており、指摘の修正や確認作業にかかる時間が約90%削減されていました。

さらに、今回の制作担当者にヒアリングしたところ、以下のような意見を頂きました。

  • 制作が楽になった、時間短縮された
  • 新規の場合、1から作る手間が省ける
  • 流用する場合、そのまま使えない(改題しないといけない)のでその手間が省ける
  • 単語の差し替えくらいで済む

(2)制作費用と運用費用

制作にかかる労力が軽減されたことで、図1の工程1から3で1問あたりにかかる費用が38%削減されました。

また、英語の問題制作では、工程3でネイティブチェックが必要となります。今回はほとんど修正されることがなかったため、そこでかかる費用が大幅に削減されていました。

さらに、Classiの学習トレーニング機能では、課題配信機能や生自主学習機能により数十万人もの生徒に問題が利用されます。
問題を流用した場合にかかるはずだった利用料が、今後100%削減され続けます。

最後に

今回は、英文法問題を自動生成してリリースした話をしました。
問題の制作工程の中でも最もコストのかかる「問題選定」部分に着目し、生成AIをはじめとする自然言語処理技術を組み合わせて問題を自動生成できるアプリを構築しました。

評価や検証の結果、自動生成された問題はリリース作業に利用できる品質であり、かつ、制作コストの削減への効果も大きいことが分かりました。
さらに、今回の取り組みにおいて研究的成果をまとめ、言語処理学会第30回年次大会にてポスター発表も行っています。

prtimes.jp

一方で、今回対象とした問題は英文法問題のみとなっています。
今回のロジックを英単語の意味を問う問題や英文法の並べ替え問題などの制作に応用することは可能だと考えられます。
しかし、教科が変わった場合、今回のロジックをそのまま応用できるとは限りません。

このような技術の汎用性は今後も課題となるので、まずは、現状のロジックの応用範囲を明らかにしつつ、問題制作のニーズや制作される問題の汎用性などを考慮して、着手する対象を決めていきたいです。

また、生成AIの教育現場への活用という広い視野で考えると、コンテンツ制作だけでなく、生徒の新しい学習体験の創出や先生の業務支援なども考えられます。

今回のように、大学や他チームと協業して、このような新たな価値を探索していく活動は継続していきたいです。
そして、ビジネスとアカデミックの両方にインパクトを与えられるような成果を発信していきたいと考えています。

*1:坪田 彩乃, 石井 秀宗: 多枝選択式問題作成ガイドラインの実証的検討, 日本テスト学会誌, 2020, 16 巻, 1 号, p. 1-12

株式会社万葉のみなさんとエンジニア交流会を実施しました

こんにちは、エンジニアの id:kiryuanzu です。

2024年の1月に Classi のオフィスで 株式会社万葉のエンジニアの方々と交流会を実施しました。

今回の記事では万葉さんとの交流会の様子を紹介した上で、他社との交流会を実施してみての振り返りレポートを紹介します。

経緯

去年の RubyWorld Conference の交流イベントの場で万葉の大場さんとお会いし、コードレビューに関する悩みを相談したところ、とても丁寧にアドバイスしていただきました。

tech.classi.jp

ベテラン Rubyist の方からコードレビューについての考え方を熱弁していただいたりなど楽しい出来事がたくさんありました。

(この時のベテラン Rubyist の方が大場さんです)

その時の会話が大変楽しく、大場さんを始めとした万葉の方々と Classi のメンバーを引き合わせてお話しできたら楽しそうだと考え企画しました。

当日の様子

交流会はオフライン形式で西新宿にある Classi のオフィスのオープンスペースを使って実施しました。
万葉からは大場さん、鳥井さん、櫻井さん、児玉さんの4名、Classi からは id:kiryuanzu、中島、中橋、onigra の4名が参加し合計8名で1つのテーブルを囲んで3時間ほどお話しをしました。

当日は同僚おすすめの店で注文したご飯とお酒をみんなでつまみながら以下の話題で盛り上がりました。(交流会中の写真は撮り忘れていました……🙏)

  • コードレビューする際に考えていること
  • 新卒教育についての取り組み
  • 教育プロダクトについての課題感や今後注力したいこと
  • エンジニア面接でよくする質問とその背景
  • 技術系の地域コミュニティについて

万葉のみなさんも教育に強い関心があるようで、学校訪問時のエピソードにとても興味を持ち話を聞いてくださったのが印象的でした。
他にも、Classi のメンバーから面接時の振る舞いについての悩みの相談があり、選考時に大事にしている考え方について教えていただきました。そのように、お互いの普段の業務での考え方を共有して知見を得るといった場面が多くありました。

交流会を実施してみての振り返り

今回筆者が他社との交流会を初めて企画したことでいくつか発見があったので紹介します。

よかったこと

バックグラウンドの近い人たちと情報交換できると共感しやすい

先ほども紹介した通り、教育業界に興味のある方が参加者に多く教育トークになるとかなりの盛り上がりを見せていました。コードレビューや新卒教育での取り組みでも、「これは参考にしたいね」と盛り上がって互いに共感できる要素が多かったように感じました。

一緒に参加した Classi のメンバーからは「初対面とは思えない話しやすさがあった」という感想をいただけたのが大変嬉しかったです。

小規模構成(4対4)だと各参加者の話をじっくり聞きやすい

今回は大人数を集めてワイワイやるというよりかは、少人数で1つのテーブルを囲んでゆったりと話す場となりました。
大規模なイベントだとどうしても特定の人と長く話すのが難しいことが多かったのですが、今回の場をセッティングしてずっと気になっていた方達と腹を割って話す機会を作ることができました。
今後もカンファレンスなどの場で「この方ともっと長く喋ってみたいな / 社のメンバーに紹介したいな」と感じたら思い切ってセッティングしてみたいです。

次回も意識したいこと

企画にあたって交流会のスコープをちゃんと共有した方がよい

交流会と一言でいっても様々なパターンがあり、どういった趣旨の会なのか参加者に事前に共有しておくのが大事だと感じました。
今回は本記事の経緯で述べた体験をメンバーにも共有してもらいたいという前提があったため、協力してくれたメンバーには「万葉の方々とお酒を飲みながらコードの話をする会をしたいです」と説明して会の温度感を汲み取ってもらいました。万葉さんにもその趣旨を伝えてスムーズに認識合わせができました。

また、今回は意識していなかったのですが、少人数の交流会であるなら参加者のプロフィールを事前共有しておくとどんなバックグラウンドを持った方かどうかイメージしやすく会話の場作りに役立ったのではないかと思いました。

ご飯どころに詳しい同僚におすすめのケータリング情報を教えてもらう

筆者は新宿のご飯事情に疎いのですが、ご飯どころに詳しい参加メンバーにおすすめのテイクアウト可能なお店情報を共有していただき大変助かりました。当日は注文した料理をテイクアウトで受け取り、小田急ハルクにある KALDI とビックカメラでクラフトビール等の飲み物を購入しました。
食事のラインナップについても一捻り考えられると会の楽しさが増すように感じ、次回やる時も意識しておきたいポイントとなりました。

次回は意識したいこと

ブログ公開用の写真を撮っておく

当日の様子でも触れていますが、交流会での写真を撮り忘れてしまいました……。これが今回の一番の反省ポイントです。
写真が一枚でもあれば今回の記事で場の雰囲気を伝えられる素材として活用できたはずなのですが、会の話に夢中になってしまい撮り忘れてしまいました。次回は必ず撮るようにしたいです。

おわりに

改めて交流会に参加していただいた万葉のみなさま、ありがとうございました!
企画に協力してくださった Classi のメンバーの方々にも感謝です。
とても素敵な体験となり、今後もこういった他社との交流会を開催できるように動いていきたいです。
読んでいただきありがとうございました。

万葉のみなさんからの感想コメント

この記事の公開に合わせて、交流会に参加していただいた万葉のみなさんからの感想コメントをいただきました。みなさんが会を楽しんでいただけたことが伝わってくる内容で嬉しい限りです。お忙しい中、記事の作成にご協力いただきありがとうございました!

鳥井さん

Classiさんとの交流会楽しかったです! Classiさんの採用やPRでのコミュニケーションの話に「わかる」となったり知見をいただいたり、リアルな教育現場と関わるお仕事の難しさと熱意を感じたりと、大変有意義な時間でした。おいしいお肉とビールありがとうございました

櫻井さん

コードやコミュニケーション、面接やesaの工夫などいろいろお話しできて、とてもたのしかったです!塾や中受の話も含めて、「人を見る、人を育てる」ことの難しさや楽しさを言語化できるよい機会になりました。ありがとうございました〜!

大場さん

記事に書いていただいたのと同じような感想を抱いていました。教育系のお話、PRの書き方の話などが非常に興味深く参考になりました。チキンも美味しゅうございました! お誘い感謝です!!

児玉さん

プロダクトの課題からコードレビューに至るまで幅広いトピックについて深く議論できた交流会で、あっという間に時間が過ぎました。特に、PRのタイトルの付け方については、自分のやり方を振り返る良い機会になりました。素敵な機会を設けていただき、ありがとうございました!

たった1行のPRでチームの"速さ"を可視化できる計測基盤を作った話

こんにちは、データプラットフォームチームの鳥山(@to_lz1)です。エンジニアの皆さん、自分のチームのパフォーマンス、計測していますか?

DevOps Research and Assessment(DORA)の2019年のレポート により、開発チームのパフォーマンスを示す指標として提唱された「Four Keys」。この中に「デプロイ頻度」「変更のリードタイム」という指標があります。

『LeanとDevOpsの科学』など有名な書籍で取り上げられたこともありFour Keysそのものが広く知られるようになりましたが*1、この度Classiでもこれらの指標を可視化するダッシュボードを構築し、社内提供を始めました。

計測の事例がインターネット上に多くある中でも、

  • 横展開を極力容易にするための設計
  • リリースをしてみてから「実際に役に立てる」までの工夫とフォローアップ

といった辺りに独自性があるかと思うので、その内容について本記事で紹介していきます。

「変更のリードタイム」の定義

まず最初に、「変更のリードタイム」の定義について整理しておきましょう。

Googleのブログ 中には「commit から本番環境稼働までの所要時間」と表現されていますが、単純なようでいてさまざまな「定義の揺れ」を引き起こしています。例えば、以下のような議論があります。

「本番環境稼働まで」というのは明確なのですが、「commit から」というのがどうにも曖昧で、よくわからないのです。なにかの時間を測るには start と end が必要ですが、この start にいくつかの解釈があるのです。*2

ここでいうstartの解釈には、以下のようなものがあります。

  • 開発チケットが作られた日時
  • 開発チケットが IN PROGRESS 相当のステータスになった日時
  • 担当者が実装を完了し Pull Request (以下、PR) を出すための commit をした日時
  • PR のレビューやQAが終わり、あとは deploy を待つだけの merge commit をした日時

そして、どれが正解か?というと実は難しいです。先に引用した記事を読む限り、最も原義に近いのは「あとは deploy を待つだけの merge commit をした日時」だと思われます。しかし、それでは「マージからデプロイまでが自動化されているか(≒DevOpsの練度)」を測ることはできても「PRのマージまでに何が起きているのか」は見えてきません。我々開発者からすると、現代において「開発生産性」を測るには不十分な印象が否めないのではないでしょうか。

筆者は「どれが最適かは計測のしやすさや理解のしやすさにより、組織ごとにカスタマイズした定義を用いれば良い」という立場に立っています。今回の取り組みでは、最終的に以下の2つの指標を組み合わせて使うことにしました。

  1. 開発者がPRを出してから、マージされるまでの時間
  2. mainブランチにマージされた全てのコミットの、作成からデプロイ完了までの時間

「開発者がPRを出してから、マージされるまでの時間」が長くなっている場合は、PRを出してからレビューするまでのプロセスに課題があると考えられます。例えば、

  • PRの粒度が大きすぎる
  • 開発者のタスクが多すぎてレビューの時間や知識移転の時間が十分に取れていない

などです。

「開発者がPRを出してから、マージされるまでの時間」は短いのに、「mainブランチにマージされた全てのコミットの、作成からデプロイ完了までの時間」が長いという場合は、PRを出す前、またはPRのマージ後に課題があると考えられます。例えば、

  • 開発着手してからPRを出すまでに要件の確認が多く発生している
  • マージしてからデプロイするまでが適切に自動化されていない

などです。このように、「指標に大きな増減があった時に、何かチームの健康状態に関する情報が得られそうか」という点を意識しながら指標を設計・改善していきました。

実際に作ったもの

現時点では、Redashを使って開発チームにダッシュボードを公開しています。

ダッシュボードの全体像と一部リポジトリの実データ。年末年始に休みがあったことがわかります

複数のリポジトリを選択できるようにしている点は特徴的かと思います。

Classiは大きな機能ごとに開発チームを分ける、フィーチャーチームに近い体制を取っています*3。更にこの中で、「あるチームが複数のリポジトリを管理する」といったことも当たり前です。それ故に、「チームごとにリポジトリを選べること」「複数のリポジトリを横断してデータを可視化できること」が必須の要件だったのでこのような実装にしました。

また、ダッシュボード下部には「直近マージされたPRの一覧」もあります。

リンク付きのPR一覧
リンク付きのPR一覧

RedashのTableは、デフォルトではクエリ結果に含まれるhtmlをエスケープせずそのままレンダリングするため、これを利用してPRへのリンクを付けています*4。以下のようなクエリで手軽に実現できるので便利です。

 select
   repository_name,
   pull_request_title,
   base_ref_name,
   author_login,
   automated_pull_request,
   merged_at,
   merge_duration_hour,
   '<a target="_blank" href="https://github.com/{organization_name}/' || repository_name || '/pull/' || pull_request_number || '">link🔗</a>' as link_to_pr
 from 
   `four_keys.merged_pull_requests`
 where
   repository_name in ( {{ Repositories }} )
   and merged_at >= date_sub(current_date('Asia/Tokyo'), interval 14 day)
 order by 
   merged_at desc

設計と利用技術

パイプライン全体の構成図は以下の図のようになっています。

GitHubから抽出したデータはload =&gt; transformの流れを経てRedashで可視化される
パイプライン全体の構成図と利用技術

Classiには多数のリポジトリがありますが、巨大な開発組織ではないのでマージされるPRの数は週に数百オーダーといったところです。このため、データプラットフォームチームで運用しているCloud Composerをバッチ実行基盤とし、日次でデータをPullするようなアーキテクチャを選択しました*5

認証には、 GitHub App として発行したインストールアクセストークンを用いています。有効期間が短いトークンを処理の都度発行するため、セキュリティレベルを保ちながら、PATを人手で更新するようなtoilをゼロにできます。

また、データの収集には GraphQL API を採用しました。GitHubのGraphQL APIは実務では初めて触ったため最初はスキーマの理解が大変でしたが、公式に提供されている GraphQL API Explorer の利便性が素晴らしく、効率よく理解を深められました。

取得したデータはjsonlファイルとしてGCSに保存し、 Cloud Storage BigLake Table として参照しています。図中の点線部分がこれにあたり、実体となるテーブルをわざわざ作らなくてもjsonlファイルをSQLで即座にクエリできるので、大変便利な機能です。Connectionという概念を挟んでいることで権限の管理もしやすく、これも使ってみて嬉しいポイントでした。

BigLake Tableは メタデータキャッシュ という機能を持っており、特に大量のファイルを扱うようなケースでクエリを効率的に処理してくれます。しかし、メタデータキャッシュが更新されるまでは新たに追加されたファイルがクエリできないなど、面倒な点もあるので、今回のようなユースケースではバッチ処理の都度BigLake Tableを再作成してしまうのも手かと思います。我々のチームでは以下のようなコードで毎回の処理の最後に create or replace external table 文を流しています。

 if __name__ == "__main__":
     # ... (省略)
     gql_client = get_gql_client()
 
     pull_requests_file_name_suffix = "merged_pull_requests"
     data = extract_pull_requests(gql_client, date_from, date_to)
     upload_blob(data, pull_requests_file_name_suffix)
     create_or_replace_external_table(pull_requests_file_name_suffix)
     # ... (省略)
def create_or_replace_external_table(file_name_suffix: str) -> None:
      bigquery_client = bigquery.Client(project=DATAPLATFORM_PROJECT_ID)
      query_job = bigquery_client.query(
          f"""
          CREATE OR REPLACE EXTERNAL TABLE
          `{DATAPLATFORM_PROJECT_ID}.github.{file_name_suffix}`
          WITH CONNECTION `{DATAPLATFORM_PROJECT_ID}.{BIGQUERY_CONNECTION_NAME}`
          OPTIONS(
              format ="NEWLINE_DELIMITED_JSON",
              uris = ['gs://{BUCKET_NAME}/*_{file_name_suffix}.jsonl'],
              max_staleness = interval 1 day,
              metadata_cache_mode = AUTOMATIC
              );
      """
      )
      # wait for job to complete.
      _ = query_job.result()

工夫した点

以下では、開発や導入推進における工夫点を紹介します。

「1行のPRで導入できる」体験設計

序盤から全リポジトリを収集対象にしても良かったのですが、データ量が膨れ上がっても困るため、「利用者側からPRを出してもらって対象リポジトリをOpt-in的に追加していく」という方式を取りました。

リポジトリの追加作業が大変では誰も使ってくれないということが容易に予想できるので、実際の変更は対象リポジトリ名の配列に要素を追加するだけで済むようにしました。

他チームエンジニアが出してくれた1行のPR
これだけのPRで、このチームも明日から生産性指標を可視化できます!

このPRは実際に利用者側のチームメンバーから出して頂いたものです。場合によってはデータの過去遡及反映が必要ですが、これもデータプラットフォームチーム側で一度ジョブを流せば完結するような設計にしています。これらのプロセスを簡略化したことで案内や運用も非常にやりやすかったため、アプローチとして正解だったと思っています。

ベストプラクティスの緩やかな強制

ダッシュボードには「デプロイ頻度」のウィジェットもありますが、チーム横断で計測するには妥当性と実現性のある「デプロイの定義」を決めなければなりません。

縦軸の定義が書かれていない「デプロイ頻度」の棒グラフ
デプロイ「1回」とは何か。簡単なようで、チームごとに違う場合もありますよね

これについては、開発初期から「GitHub Environmentを用いた、”production”という名前が付いた環境に対するDeploymentだけを計測対象とする」という方針を定めました。

GitHub Environmentは、環境ごとの変数の定義や、きめ細やかなレビュー・デプロイ時の制約を設定できる便利な機能です。

デプロイに環境を使用する - GitHub Docs

導入もGitHub Actionsのyamlに1行書くだけと非常に簡単なので、新たに導入してもらう場合でもコストパフォーマンスが良いだろうと判断しました。

Googleのブログでは、プラットフォームエンジニアリングに関して以下のような記述があります。

組織において有用な抽象化を行い、セルフサービス インフラストラクチャを構築するアプローチです。散乱したツールをまとめ、デベロッパーの生産性を高める効果があります。

作ってみてから思ったことではありますが、このように緩やかにベストプラクティスを推進する設計もまた、プラットフォームエンジニアリングの一例と言えるかもしれません。

実利用の現場への入り込み

パイプラインとダッシュボードの初期開発は2週間程度で完了しましたが、実際に利用を普及させるまでには宣伝と利用者ニーズを踏まえた改善が欠かせません。

これについてはどのチームにアプローチするのが良いかマネージャとも相談し、対象のチームに可能な限り深く入り込んで活用のチャンスを探りました。具体的には、

  • そのチームのミーティングに参加して普段のデータに対する接し方を見る
  • リードタイムを集計する既存の取り組みについて聞き、課題感を知る

などです。後者に関しては先日の別記事でも紹介がありましたが、チームの振り返り会の中でリードタイムなどの指標をうまく使っている事例があると分かりました。

tech.classi.jp

PRの粒度が大きい場合や複雑な場合にリードタイムがかなり伸びるということがわかりました。

開いた時に難しそうだと感じるPRは、レビューを後回しにするのではなく、ペアレビューチャンスだと思うようになりました

この取り組みを更に支援すべく、当該チームがシェルスクリプトとスプレッドシートで集計していた指標をダッシュボードにも盛り込み、既存オペレーションからの移行を推進しました。

この結果、チームメンバーからも「手動でぽちぽち計測しなくてもよくなったのでとても楽になって助かった」といったフィードバックをもらえました。

今後の展望

指標を追加する

本記事中でもFour Keysという言葉を何度も使ってきました。当初は「Four Keysをいい感じに可視化しよう」という意図もあったのですが、現在のところは「デプロイ頻度」と「変更のリードタイム」のTwo Keysしか測っていません。

デプロイの頻度と変更のリードタイムは速度の指標であり、変更障害率とサービス復元時間は安定性の指標*6

とも言うように、現在計測できているのは「速度の指標」だけということになります。

とはいえ、変更障害率とサービス復元時間が安定性を測る上で最適かと言うと、また議論の余地があると思っています。このため、ダッシュボードにはこれらの指標をしばらく実装する予定はありません*7

Classiでは現在、SREチームがSLI/SLOの整理と策定を進めてくれている*8ため、これらの取り組みと合流するなどの道も考えられます。筆者としては、「開発チームの気づきを適切に促すこと」がまず第一目的であり、その目的に合致する指標を都度磨き上げていけば良いだろうと考えています。

より定量的な成果に繋げる

今のところ得られたフィードバックはポジティブながら定性的なものが多く「成果」としては弱いです。PRのマージまでの時間を見るのも良いですが、理想を言えば、デプロイ頻度や変更のリードタイムまで含めて向上できたか、更にはリリースした機能が実際にユーザに届くようなインパクトを出したか、という点を語ることこそが重要ではないでしょうか。

そうした会話が当たり前になるためには、メンバー個人の成長も、エンジニアという枠を超えたチームの成長も必要かもしれません。そのような気づきと議論を促進できる指標が何か発見できれば、それを速やかに可視化して提供するのもデータエンジニアの今後の役割だと思っています。

まとめ

Classiの開発チームの「速さ」を測定し、可視化し、気づきを得るためのダッシュボードについて、技術的な面と利活用の面からお話ししました。

Classiでは開発者の体験を良くするために技術を駆使するエンジニアも、最高の開発者体験を享受しながら教育業界に価値を生み出すチャレンジをするエンジニアも、絶賛募集中です。興味をお持ちになった方は、ぜひカジュアル面談や面接に応募してみて下さい!

hrmos.co

hrmos.co

*1:four keysリポジトリは2024年1月にアーカイブされており、率直なところハイプサイクルの幻滅期に至った感覚はあります

*2:https://zenn.dev/junichiro/articles/481b6a0658ba03

*3:現体制が選択されるに至った背景はこちらの記事でお読み頂けます https://tech.classi.jp/entry/2023/08/31/100000

*4:もちろんサニタイズはされます。ref: https://redash.io/help/user-guide/visualizations/table-visualizations

*5:扱うデータが膨大であれば、イベント駆動でデータをPushする構成が望ましいでしょう

*6:https://cloud.google.com/blog/ja/products/gcp/using-the-four-keys-to-measure-your-devops-performance

*7:指標の元になるデータを「あらかじめ収集しておく」ことは言うまでもなく重要です。この観点で言うと、障害報告をチーム横断で管理するスプレッドシートが存在するので、少なくとも「サービス復元時間」は容易に算出できます。

*8:https://tech.classi.jp/entry/2024/02/13/120000

リードタイムを測るシェルスクリプトを作ってチームの振り返り会を活発にした話

こんにちは。エンジニアのすずまさです。

去年の夏頃にリードタイムの計測を始めてから、振り返りで良い気づきを得られるようになったりリードタイムを減らすアクションが生まれたりと良いことがたくさんあったので、今回はその紹介をしようと思います。

リードタイムの定義

LeanとDevOpsの科学』では、リードタイムを「コードのコミットから本番稼働までの所要時間」として定義しています。

私たちのチームのリポジトリではブランチ戦略としてGitHub Flowを採用しており、mainへのマージと本番稼働のタイミングが近しいため「PRをopenしてからマージするまでの期間」をリードタイムとして定めて計測しました。

リードタイム計測を始めた動機

私たちのチームでは「チームのスピードがあまり出ていない気がする」という漠然とした課題感がありました。しかし、課題感はありつつも、ではどうするかと言われると具体的なアクションが出にくい状態が続いていました。

そこで、リードタイムを測ることでボトルネックになっている箇所を明確にし、デリバリのスピードを上げたいと思い計測を始めることにしました。

計測方法

計測方法については、後半に説明する通り自動化できていないなどの改善点がいくつかありますが、一例として誰かの参考になればと思い紹介します。

私たちのチームではGitHub Projectsを使ってPRやissueの管理をしており、クローズしたアイテムは完了レーンに置くようにしていました。
単純に管轄リポジトリのリードタイムを計測するだけだと、複数チームが使うリポジトリの場合に他チームの結果も混ざってしまいます。そこで、「GitHub Project上の完了レーンにあるアイテム」をリードタイム計測の対象とし、計測したアイテムは削除するような運用にしました。

私たちのチームで使っているGitHub Project

実装はシェルスクリプトとRubyで行いました。
シェルスクリプト上でGitHub APIを叩いて完了レーンのアイテムを取得し、Rubyを使ってリードタイムを計算して出力しています。

当初は「完了レーンにあるアイテムを取得するだけならGitHub APIに専用のクエリがありそう」と考えていましたが、そのようなクエリは見当たらなかったため、下記のように実装しました。

  1. GitHub CLIを使ってGitHub APIを叩き、projectのidを取得する
  2. リポジトリを列挙してfor文で回し、下記を繰り返す
    • GitHub APIのsearch queryを使って直近でクローズしたPR/issueを100件取得する
    • 取得したアイテムのうち、対象のproject idの完了レーンに存在するものをJSONファイルの末尾に追加
  3. JSONファイル内の各アイテムのリードタイムを計算して出力

実装は下記の通りです。

シェルスクリプトを使った実装 (1, 2)

#!/bin/bash

PROJECT_NUMBER="1" # GitHub ProjectsのURLに記載されている数値

gh api graphql -f query='
  query ($org: String!, $project_number: Int!) {
    organization(login: $org) {
      projectV2(number: $project_number) {
        id
      }
    }
  }' -f org="classi" -F project_number=$PROJECT_NUMBER >project.json

PROJECT_ID=$(jq -r '.data.organization.projectV2.id' project.json)

repositories=(
  # 計測対象のリポジトリ名を格納する
)

echo -n "[]" >completed_items.json

for ((i = 0; i < ${#repositories[@]}; i++)); do
  search_query="repo:classi/${repositories[i]} is:closed sort:updated-desc"

  gh api graphql -f query='
    query ($search_query: String!) {
      search(type: ISSUE, first: 100, query: $search_query) {
        nodes {
          ... on PullRequest {
            title
            url
            repository {
              name
            }
            assignees(first: 10) {
              nodes {
                login
              }
            }
            labels(first: 10) {
              nodes {
                name
              }
            }
            createdAt
            closedAt
            projectItems(first: 10) {
              nodes {
                id
                project {
                  id
                }
                fieldValues(first: 10) {
                  nodes {
                    ... on ProjectV2ItemFieldSingleSelectValue {
                      field {
                        ... on ProjectV2SingleSelectField {
                          name
                        }
                      }
                      name
                    }
                  }
                }
              }
            }
          }
        }
      }
    }' -f search_query="$search_query" >search_result.json

  # GitHub Project上の"完了"レーンにあるアイテムをtempに格納
  temp=$(
    jq --arg project_id "$PROJECT_ID" '
      .data.search.nodes[] |
      select(. != {}) | select(.projectItems.nodes[] |
      .project.id == $project_id) |
      select(.projectItems.nodes[] |
      .fieldValues.nodes[] |
      select(.field.name == "Status").name |
      contains("✅ 完了"))
    ' search_result.json |
      jq -cs .
  )
  # temp配列の値をcompleted_items.jsonの配列の末尾に追加する
  completed_items=$(jq -s '.[0] + .[1]' completed_items.json <(echo "$temp"))
  echo "$completed_items" >completed_items.json
done

bundle exec ruby format_pr_infos.rb

Rubyを使った実装 (3)

  • format_pr_infos.rb
require "json"
require "./completed_item"

rows = ''

File.open("completed_items.json") do |f|
  completed_items = JSON.load(f)

  completed_items.each do |completed_item|
    next if completed_item.empty?
    item = CompletedItem.new(completed_item)

    rows+="#{item.title}\t"
    rows+="#{item.repository}\t"
    rows+="#{item.assignees}\t"
    rows+="#{item.labels}\t"
    rows+="#{item.lead_time}\t"
    rows+="#{item.formatted_lead_time}\t"
    rows+="#{item.created_at}\t"
    rows+="#{item.closed_at}\t"
    rows+="#{item.url}\n"
  end
end

puts rows
  • completed_item.rb
require 'time'

class CompletedItem
  def initialize(item)
    @item = item
  end

  def title
    @item['title']
  end

  def repository
    @item['repository']['name']
  end

  def created_at
    Time.parse(@item['createdAt']).getlocal("+09:00").strftime("%Y-%m-%d %H:%M:%S")
  end

  def closed_at
    Time.parse(@item['closedAt']).getlocal("+09:00").strftime("%Y-%m-%d %H:%M:%S")
  end

  def lead_time
    "#{format("%02d", hour)}:#{format("%02d", minute_per_hour)}"
  end

  def formatted_lead_time
    "#{format("%03d", date)}#{format("%02d", hour_per_day)}時間 #{format("%02d", minute_per_hour)}"
  end

  def assignees
    assignees_nodes = @item['assignees']['nodes']
    assignees_nodes.map{ |node| node['login'] }.join(',')
  end

  def labels
    labels_nodes = @item['labels']['nodes']
    labels_nodes.map { |node| node['name'] }.join(',')
  end

  def url
    @item['url']
  end

  private

  def time
    @time ||= (Time.parse(closed_at) - Time.parse(created_at)).to_i
  end

  def date
    @date ||= (time / (60 * 60 * 24)).floor
  end

  def hour
    @hour ||= (time / (60 * 60)).floor
  end

  def minute
    @minute ||= (time / 60).floor
  end

  def hour_per_day
    @hour_per_day ||= hour - date * 24
  end

  def minute_per_hour
    @minute_per_hour ||= minute - hour * 60
  end
end

出力した内容は、振り返りやすくするためにGoogleスプレッドシート上に貼り付けて記録するようにしました。 スプレッドシートのフィルタ機能を使えば、任意の期間や担当者などで条件を絞り込み、その条件のリードタイムの平均値や中央値を算出できます。

毎週、リードタイムを記録する際にその週で絞り込んだフィルタを作成することで、後から簡単に見返せるようにしました。

チームの変化

リードタイムを計測してから下記のような良い変化が起きました。

PRの質を意識するようになった

リードタイムを計測したことで、PRの粒度が大きい場合や複雑な場合にリードタイムがかなり伸びるということがわかりました。

粒度の大きいPRは見る箇所が多いので、当然その分レビューにも時間がかかります。
ただ、それだけではなくレビュー自体が後回しにされていたことも原因の一つでした。

レビュアーが「レビューしづらそう」と感じたPRのレビューを後回しにしており、それによりさらにリードタイムが伸びていました。

この気づきにより、レビューしづらいなと感じても後回しにせず、まずはPRの質に関する指摘を積極的にするようになったと感じます。

また、PRを作る側もレビューしやすいPRを意識するようになっており、チームメンバーに対してリードタイム計測前後でどんなアクションを取るようになったかアンケートをとったところ「PRを小さく作るようになった」「descriptionを細かく記述するようになった」という声が寄せられました。

リードタイム計測後に行ったアンケートの回答

ペア/モブ作業が増えた

上記はPR自体の問題によるものでしたが、PR自体に問題はなくても前提の理解が大変だったり、チームメンバーの苦手分野が集まっている変更だったりするとリードタイムが伸びてしまいます。

そういったPRがあった場合は、レビュアー側からPR作成者にペアレビューを呼びかけてリードタイムの短縮に努めました。

ペアレビューを呼びかけると「私もこのPRよくわかってないので参加したいです」というような声が上がって3~4人のモブレビューになることもあり、PR作成者とレビュアー間の知識差を素早く埋めることができるのでかなり体感が良かったです。

開いた時に難しそうだと感じるPRは、レビューを後回しにするのではなく、ペアレビューチャンスだと思うようになりました。

振り返り会で良い気づきを得られるようになった

上述した変化はいずれもチームの振り返り会の中で生まれたアクションです。

ボトルネックが明確になったことで、今まで曖昧だった問題点を捉えやすくなり、振り返り会が活発になったように感じます。

具体的には、振り返り会の中で下記のような問題点に気づけました。

  • チームメンバーの苦手領域が集まっているPRは明らかにリードタイムが伸びている
  • 問い合わせ対応が多い週はリードタイムが伸びやすい
  • タスクを抱えすぎてレビューに手が回らなくなっている

特に計測を始めて一ヶ月くらいは、振り返り会のたびにチームが改善していっている実感があり、チームで働くモチベーションになっていました。

現状の問題点

リードタイムを計測すること自体は良いことづくめなのですが、今の計測方法には下記のような問題点があります。

  • 手動実行が面倒臭い
  • 他チームで流用しづらい

振り返り会の前に手動で実行して、Googleスプレッドシート上に貼り付けて、フィルタ作成して…ということを毎週やっており、かなり面倒です。 手動なのでオペミスが起きることもあり、一度過去に作成したフィルタを全部削除してしまったこともありました。

また、「GitHub Projectの完了レーンを参照する」という自分たちのチームの運用方法に依存した方法にしてしまったため、似たような運用をしているチームではないと真似するのが難しくなっています。

ではどうするのか

「各々のチームがリードタイムを計測しても他チームへの横展開が難しい」という問題点は以前から存在したため、最近データプラットフォームチームが横断的にFour Keysの可視化をするための基盤を作ってくれました! この基盤を利用すれば上述した問題点は全て解決できそうなので、徐々に移行を進めようと考えています。

今後うまく移行できればまた紹介しますので、ご期待ください!

結び

以上、私のチームで行なっているリードタイムの計測方法と、計測したことで起きたチームの変化についての紹介でした。

計測の仕組みを整えるまでにかかるコストに対して得られる気づきは大きかったので、費用対効果が高くやって良かったなと感じました。

少しでもリードタイムを計測しようと思っている方の助けになれば幸いです。 ここまで読んでいただきありがとうございました。

SLO本読書会、監訳者である山口能迪さんを囲んでSLOの理解を深める会を実施しました

こんにちは。プロダクト本部プラットフォーム部SREチームの id:ut61z です。

SREチームが主体となってSLO本読書会を社内で実施しました。

そしてなんと贅沢なことに、監訳、翻訳に携わった Google Cloud の Developer Relations Engineer である山口能迪さん(@ymotongpoo、以下山口さん)にゲストとしてお越しいただき、読書会で出てきた疑問をぶつけてSLOについて理解を深める会を開催しました。

今回は読書会、そして山口さんを囲んでSLOの理解を深める会について、経緯や読書会の進め方、学んだことなどをレポートしたいと思います。

SLO本とは?

O'REILLY から翻訳書が出版されているこちらの『SLO サービスレベル目標』という書籍(以後:SLO本)です。

www.oreilly.co.jp

SLI・SLO・エラーバジェットの3つについて、概念の説明、どのように設計・運用するか、さらには組織にそれらを浸透させるためにどのような文化の醸成が必要かなどがわかりやすく記されています。

第Ⅰ部 〜第Ⅲ部の三部構成で、以下のようなトピックを扱っています。

  • "第Ⅰ部 SLOの開発" では主にSLI・SLO・エラーバジェットの概念と考え方についての紹介
  • "第Ⅱ部 SLOの実装" ではSLI・SLO・エラーバジェットの導入・運用の実践的なプラクティスや事例の紹介
  • "第Ⅲ部 SLOの文化" ではいかに組織にSLOを根付かせるか、実践的なプラクティスの紹介

SLO本読書会をしようと思ったきっかけ

読書会をしようと思ったきっかけは、2点あります。

1点目は、弊社では一部の機能で限定的にSLOを設定していますが、その他の多くのコンポーネント、あるいはサービス全体に対してのSLOの設定・運用ができているわけではなく、この本を通してSLOへの理解を深め、導入・運用の再出発を図りたかった点が挙げられます。

以下の記事でSLOを導入した機能について紹介しているので、興味があればぜひご覧ください。

tech.classi.jp tech.classi.jp

2点目はSLO本を先行して読んでみて、SLI・SLO・エラーバジェットは、エンジニアだけでなく幅広い職種のメンバーと一緒になってつくりあげる必要性を感じたので、エンジニアに限らず広く社内で読書会参加者を募り、読書会を通じて概念の共通理解を得たかった、という点がありました。

読書会をどう進めたか

2点目の観点から、社内の全メンバーに対して参加の呼びかけを行いました。
結果、エンジニアに限らず、QA、デザイナー、プロダクトマネージャーなど多様な職種のメンバーが参加してくれました。

また、エンジニア以外のメンバーも参加することも踏まえ、読書会のゴールを概念の理解とし、第Ⅰ部のみを読み進めるかたちとしました。
例外的に第1章で、第17章(最終章)を読むことを強く推奨していたので、第17章も読書会で扱いました。

1章ずつ担当を割り振り、担当者はその章を事前にまとめ、発表し、参加者同士で気になったことをディスカッションをするかたちで読書会を進めました。

読書会を通して学んだこと・浮かんだ疑問

学んだことは多岐にわたりますが、前提となる考え方について、エンジニア以外のメンバーも含めて共通理解を得られたことは大きかったと思います。

たとえば以下のような点は、ディスカッションでもたびたび言及され、参加者の多くが従来の考え方をUnlearnした内容でした。

  • ユーザーを基準にして考える
  • ユーザージャーニーをベースにしてSLIを設計する
  • 信頼性は高ければ高いほどよいというわけではない、コストとのトレードオフ
  • ユーザーが不満に思わないところを見定めて目標とする
  • 信頼性についてすべてを網羅的に管理することは不可能
  • エラーバジェットは"使う"ことができる
  • SLI・SLO・エラーバジェットはプロジェクト的に設定するものではなく、継続的に文化として取り組むもの

また、エンジニア以外のメンバーから以下のような意見も出ました。

  • QA: CUJ*1からSLIを定めるという考え方は、テスト項目をつくるうえでも参考になるかもしれない
  • デザイナー: ユーザーを基準にして考えるというのは「人間中心設計」と通じるものがある
  • プロダクトマネージャー: むやみにKPI等の数値を追って完璧を目指すのではなく、重要な指標がある値であるとき、それはユーザーにとってどういう状態なのか?という会話ができるようにしたい

多様な職種のメンバー間でディスカッションすることで、多角的にSLOを捉えることができ、さらには考え方の応用ができないかの議論にも発展して、とても実りのある時間になりました。

一方で、実際にClassiでSLI・SLO・エラーバジェットを運用するとしたらここはどう捉えるべきかなど、実践的な問いもいくつか出てきました。

  • ユーザーが満足しているかはどう計測すればよいか
  • CUJとはなにかを考えてみたとき、逆にCUJじゃないジャーニーはあるのか
  • 特定のコンテキスト(時間帯・繁忙期・ユーザー種別など)によって、ユーザーの期待値は異なるのでコンテキストを加味してSLIに重み付け等をしたほうがよいか、複雑にならないか
  • エラーバジェットのバーンレートアラート*2がうまく機能していれば従来のアラートの多くはなくせる、とあるが、なくすと困るケースも出てくるのではないか

これらの疑問を携えて、山口さんを囲んでお話を伺いました。

山口さんを囲んでSLOの理解を深める会

山口さんを囲む会と題して、SLO本を読んで浮かんだ疑問や、翻訳業についてなど様々な質問をさせていただきました。

「この本の中で和訳に困った章はありましたか」と伺ったところ、第9章は統計についての話ゆえ、専門用語を正しく使うことに気をつかい、別の統計の本も参考にしながら翻訳を進められていたということを仰っていて、翻訳者としての苦労が伺えたのが印象的でした。
計算式を正しく理解するために自分で検算してみたら、原書の誤りを見つけたりもしたそうで、そういった丁寧な正確さの追求があって翻訳が成し遂げられていると思うと、改めて感謝の念を抱きました。

さて、前述の疑問をぶつけてみてお話をしていただきましたので紹介したいと思います。

Q. ユーザーが満足しているかはどう計測すればよいか

Google のUXリサーチャーチームでは、ユーザーをお呼びし、CUJのフローの操作をしてもらいながら、どこで詰まっているか(いないか)を観察する、なんでも思ったことを喋ってもらう、操作後に全体の感想をもらう、など様々なフィードバックをもらうということを行っています。

それらを踏まえて、そのCUJのSLIの妥当性をチェックします。
あくまで例えですが、ユーザーがレイテンシよりエラーに不満を持っていたなら、エラー率のSLOの優先度を上げる、といったように対応することができます。

あるいは、機能リリースなどのSLIが変化するようなイベント前後で、問い合わせがどう変化したかを見て、間接的に評価するというのも手段のひとつです。

SLIでとれるデータは、実際のエンドユーザーの満足度を直接測っているわけではなく、相関を持っているだろうとしかいえません。
最後は決めの部分もありますが、使えそうなデータはすべて用いてSLIをユーザーの満足度に近づけていくというプロセスが大事だと思います。

Q. CUJとはなにかを考えてみたとき、逆にCUJじゃないジャーニーはあるのか

たとえばあるジャーニーに対してSLI・SLOを設定してみてSLO違反が起きたときに、ビジネス上影響がないとか、問い合わせがとくにあがってこないとか、そのコンポーネントを使っている別チームも困っていない、ということであれば、実際にはそこまで信頼性が高くなくてもよかった、という気づきになり、クリティカルかどうかを見直す機会になります。

あらゆるジャーニーにSLOを定めなくてはいけないということではなくて、SLOは自分たちが効率よく信頼性を管理するために使うものなので、関係者間で「ここはSLOを定めなくてもそんなに影響がない」という合意形成ができれば定める必要はないと思います。

Q. 特定のコンテキスト(時間帯・繁忙期・ユーザー種別など)によって、ユーザーの期待値は異なるのでコンテキストを加味してSLIに重み付け等をしたほうがよいか、複雑にならないか

ブラックフライデーの時期の売上が全体の大部分を占めるというようなサービスにおいては、その時期はSLOを厳し目に変更して設定する、というのはよく聞きますし、実際にそうすべきだと思います。

SLI・SLO・エラーバジェットなどのプラクティスはあくまでプラクティスとしてまとめたものであって、実際に会社が何を優先し、何を目指すか、サービスを利用しているユーザーがどう思うかなどに寄り添うようなかたちで決めていくべきだと思います。

ユーザー種別という文脈では、 Google の例だと、 Borg という共通インフラがあり、顧客が利用するサービスも社内のレポート用に使っているサービスもすべてその共通インフラ上で稼働しています。そうすると、それぞれのサービスがリソースの取り合いになるわけですが、優先すべき顧客のサービスに対してSLOを設定して運用しています。

提供するサービスによりますが、優先したい条件のユーザーがいるのであれば、それらを識別する情報でメトリクスをフィルタし、SLI・SLOを設計することはよくあることだと思います。

複雑になってしまうという点については、そうならざるを得ない部分はあります。
なぜならビジネスが複雑だからです。

振り返りなどを通して、複雑なメトリクスのなかからユーザーの満足度に最も近いものを探っていくプロセスが大事であり、また難しい点だと思います。

Q. エラーバジェットのバーンレートアラートがうまく機能していれば従来のアラートの多くはなくせる、とあるが、なくすと困るケースも出てくるのではないか

アクショナブルなアラートかどうかというのが大事なポイントになってきます。

バーンレートアラートは、このペースでメトリクスが悪化すると、確実に信頼性が落ちていくのが目に見えているので早めにアクションを取りましょう、といったかたちで使うことができます。

一方で従来のアラートは、あるしきい値を超えると発報するように設定することが多いので、それを基準にしてアクションすることが難しい(少なくともアクションすべきかどうか判断する必要がある)という点で差異があります。

従来のアラートをやめるためには2つ条件があり、まずはバーンレートアラートがアクショナブルであるということを関係者間で実感してもらうこと、そして、従来のアラートとバーンレートアラートを比較したときに、従来のアラートがどれぐらい無視されているかを確認することです。

バーンレートアラートを通してアクションした結果、従来のアラートを無視しても大丈夫でしたよね、という事実を積み重ね、関係者間で合意ができれば、納得感をもって従来のアラートを消すことができると思います。

もちろん、バーンレートアラート以外のアラートが有効である、アクショナブルであると実感できる場合はそれを使えばよいですし、バーンレートアラート OR NOT というように考える必要はないと思います。

全体の感想

山口さんにエンジニア以外のメンバーも巻き込んでSLO本の読書会を実施している点はすばらしいというお言葉をいただき、進め方は間違ってなかったという自信が持てました。

SLO本の序文冒頭に「信頼性は会話です」とありますが、その一言にSLI・SLO・エラーバジェットをどう考えるべきかについてあらゆるエッセンスが詰まっているように感じます。

読書会や山口さんとの会を通じて、絶えずチーム・組織で会話を重ね、ユーザーが満足しているとはどのような状態か、どうすればその値を計測することができるかを追求していく姿勢、プロセスが大事だということを学べました。

改めて山口さんにはすばらしい翻訳と、理解を深める会へのご参加にこの場を借りてお礼申し上げます。

まだまだClassiはSLOを運用できていると自信を持って言える段階にはありませんが、SLOの文化を築けるように取り組んでいきたいと思います。

*1:クリティカルユーザージャーニー: ユーザーが 1 つの目的を達成するために行うサービスとの一連のインタラクションがユーザージャーニーであり、それらのうち、信頼性が損なわれるとサービスとして成り立たなくなるものを指す

*2:バーンレートアラート: エラーバジェットに段階的にしきい値を設定し、それを超えて消費されたときに発報されるアラート。エラーバジェットポリシーに則ったアクションのトリガーとなる

コスト削減成功!Amazon Auroraの監査ログをS3に保存する仕組みを構築した話

こんにちは。プロダクト本部Growth部でエンジニアをしている id:ruru8net です。
前回はこちらの記事を書かせていただきました。
tech.classi.jp

今日は前述したSRE留学中にやったことの中の「Amazon Auroraの監査ログをCloudWatch Logsを経由せずS3に保存する」を紹介したいと思います。

前提

前掲の記事にもある通り、弊社のAWSにかかっているコストを調査したところCloudWatch Logsの特にAmazon RDSの監査ログの保存にコストがかかっていることがわかりました。今回は弊社で最も使用しているAmazon AuroraのMySQLのみを対象として、監査ログをCloudWatch Logsを経由せずS3に保存する仕組みを作成しました。

作成した仕組み

こちらのオープンソースの仕組みを参考に構築、またLambdaのソースを使いました。
github.com

Lambdaを使って監査ログをS3に保存する構成図

ただLambdaはMariaDBのみの対応でAuroraに対応しておらず、MariaDBとAuroraではいくつか仕様が異なる部分があったため、Auroraで動かせるように下記の変更を加えました。

  • ログのtimestamp
  • ログファイルのprefix
  • ログファイルのローテーションタイミング

監査ログに関するMariaDB MySQLとAurora MySQLの違い

1. ログのtimestamp

  • MariaDB
    • YYYYMMDD の後、ログに記録されたイベントの HH:MM:SS (24 時間制) が続く
  • Aurora
    • 記録されたイベントの UNIX タイムスタンプ (マイクロ秒の精度)

timestampはどこまでのログをS3にエクスポートしたかを記録する際に使用するので、このtimestamp取得部分をAurora用に変更しました。

lambda/internal/parser/auditlogparser.go

// ts, err := time.Parse("20060102 15:04:05", record[0])
timestamp, _ := strconv.ParseInt(record[0], 10, 64)
epochSeconds := timestamp / 1000000
t := time.Unix(epochSeconds, 0)
formatTime := t.Format("20060102 15:04:05")
ts, err := time.Parse("20060102 15:04:05", formatTime)

2. ログファイルのprefix

  • MariaDB
    • audit/server_audit.log
  • Aurora
    • audit/audit.log

生成される監査ログのファイル名のprefixが異なるので以下の部分を変更しました。

func NewRdsLogCollector(api rdsiface.RDSAPI, httpClient HTTPClient, region string, rdsInstanceIdentifier string, dbType string) *RdsLogCollector {
  return &RdsLogCollector{
    rds:                api,
    region:             region,
    httpClient:         httpClient,
    dbType:             dbType,
-   logFile:            "audit/server_audit.log",
+   logFile:            "audit/audit.log",
    instanceIdentifier: rdsInstanceIdentifier,
  }
}

3.ログファイルのローテーションタイミング

監査ログはサーバー内でファイルに書き込まれており、一定のタイミングでログファイルの入れ替え(ローテーション)が起こります。

  • MariaDB
    • ファイルサイズがSERVER_AUDIT_FILE_ROTATE_SIZEで設定したサイズを超えたとき(デフォルト1MB)
  • Aurora
    • ファイルサイズが100MBを超えたとき
    • 100MBを超えずに最初のログの書き込みから30分が経過した時

また書き込み中のログファイルとローテーションが完了したログファイルの名前規則もそれぞれ異なります。

  • MariaDB
    • 書き込み中のログファイル名は常にaudit/server_audit.log
    • ローテーション済みのログファイル名はaudit/server_audit.log.1 のようにファイル名に数字のsuffixがつく
  • Aurora
    • audit/audit.log.A.YYYY-MM-DD-HH-MM.1のようなファイル名になる
      • A: 0-3の数字が入る
        • Auroraでは常に4つのファイルに同時に書き込みが行われているためその判別
      • YYYY-MM-DD-HH-MM: 書き込み開始時間
      • 1: 数字のsufffix。開始時間が同じファイルが複数ある場合に数字が増えていく。

今回のLambdaでのS3に送る対象のログは、Lambda実行時にすでに書き込みが完了しローテーション済みのファイルのみを送るようにしています。
MariaDBと違いAuroraはローテーション済みのファイル判別が少し複雑なため、以下のように変更をしました。

lambda/internal/logcollector/rdslogcollector.go

func (l *LogFile) IsRotatedFile() bool {
-   matched, err := regexp.Match(`\.log\.\d+$`, []byte(l.LogFileName))
+// ログファイルが100MBに達しているかどうかを確認
+   if l.Size > 100000000 {
+     return true
+   }
+// ログのファイル名のsuffixからログファイルの生成時刻を取得
+   regex := regexp.MustCompile(`audit\.log\.\d{1}\.(\d{4}-\d{2}-\d{2}-\d{2}-\d{2})`)
+   matched := regex.FindStringSubmatch(l.LogFileName)
+   if len(matched) < 2 {
+     log.Warnf("Error matching log file: %v", l.LogFileName)
+     return false
+   }
+   createdTime, err := time.Parse("2006-01-02-15-04", matched[1])
+   // ローテーション完了時刻は現在時刻(UTC)の30分前
+   rotatedTime := time.Now().Add(-30 * time.Minute)
    if err != nil {
        log.Warnf("Error matching log file: %v", err)
        return false
    }
-   return matched
+// ログファイルの作成時刻がローテーション完了時刻より前であればログファイルはローテーション済み
+   return createdTime.Before(rotatedTime)

その他変更点

Classiでは1つのクラスターに対しライターとリーダーの2つのインスタンスが存在しています。元のLambdaではRDSのインスタンスを環境変数に取っており、このままだと1つのクラスターに対し2つのLambdaが必要になってしまうので、1クラスター1Lambdaで対応できるようにしたかったため、以下の2点を変更しました。

  • RDSのクラスター名を環境変数に取るようにする
  • RDSのクラスター名からインスタンスを取得し、各インスタンスに対してS3へのエクスポートを実行する

コストの変化

S3に送るようにしたことでCloudWatch Logsへの監査ログの送信を止めたところ、CloudWatchのコストを約53%減らすことができました。(10月中旬に監査ログの送信を止めたため9月と11月を比較しています)

CloudWatchのコスト変化のグラフ

実装後の感想

監査ログという観点だけでもMariaDBとAuroraで異なる部分があり、ドキュメントにない部分は実際にインスタンスを立ててみて動かして違いを探したりしました。
また作成したインフラリソースはTerraformで管理しています。今後新しくRDSを作成した際にも楽に監査ログをS3に送れるよう、for_eachを使ってリソースを定義するなど、拡張しやすくすることも考えました。ただ仕組みを作るだけでなく、今後その仕組みを自分以外が管理、運用してすることを考えて構築していくのがとても難しく時間がかかりましたが、とてもいい勉強になりました。結果的に大きくコスト削減にも貢献することができ、より達成感を感じられました。
今回はAurora MySQLに限定したLambdaになっているのですが、弊社ではPostgreSQLを使用している部分もあるので、他のRDSにも対応したLambdaになるようアプリケーションコードの改修もしていきたいです。

RubyWorld Conference 2023 に参加しました

こんにちは!エンジニアの id:kiryuanzu です。最近はよく GitHub Actions のワークフローいじりをしています。

今回の記事は11/9(木)〜11/10(金)に筆者が参加した RubyWorld Conference 2023 の参加レポートです。

2023.rubyworld-conf.org

RubyWorld Conference とは?

RubyWorld Conference は島根県松江市で年1回開催される Ruby のビジネスカンファレンスです。通称 RWC と呼ばれています。
公式サイトでは以下のように開催概要について説明されています。

RubyWorld Conferenceは、プログラミング言語「Ruby」の国内最大のビジネスカンファレンスです。 Rubyが、多様な現実世界にどのように適合し浸透していくのか、そのような普及過程と成長を考える機会となることを期待すると共に、Rubyのさらなる普及・発展とビジネス利用の促進を目指します。先進的な利用事例や最新の技術動向、開発者教育の状況などの情報を発信することで、「Rubyのエコシステム(生態系)」を知っていただくことができる場として開催します。 開催概要 | RubyWorld Conference 2023

RWC は過去に Classiの新卒メンバーで参加した RubyKaigi とはまた別系統の Ruby のカンファレンスとなります。

Classi のエンジニア3名が RubyKaigi 2022 に参加しました - Classi開発者ブログ

しかし実は RWC にも 2019年に当時の新卒メンバーたちが中心となって参加しています。当時の記事も残っており、当時のメンバーたちが楽しくイベント参加された様子が伝わる内容でした。

「Ruby World Conference2019」にスポンサーとして参加しました!(そして島根を楽しみました!) | Classi株式会社's Blog

筆者はこの時期まだ入社しておらず、今回が初めての参加となりました。RubyKaigi には何度も参加していましたが、RWC に関しては未知のイベントでどんな雰囲気なのかよく知らないままでいました。
今回参加してみて、RubyKaigi とはまた違った魅力の詰まったイベントだと知ることができました。その感じた魅力の一部を記事の中で紹介していこうと思います。

イベントの雰囲気

まず、オープニングトークで司会の方から「スーツを着ている人が多いカンファレンス」と紹介される場面があり一笑いが起きていたのが印象的でした。企業以外にも自治体や学校が深く関わっているという説明もあり、仕事柄スーツで参加されている方の割合が他のイベントより多いようです。
実際スポンサーブースに寄ってみるとスーツ姿の方がいらっしゃって、たいへん親切に活動説明と名刺交換をしてもらいました。 筆者が今まで参加したカンファレンスだとあまりフォーマルなスタイルでの関わりが少なく、ちょっと新鮮な体験でした。

また、発表会場には松江高専の学生たちが数十人ほど固まって話を聞いている様子を見かけました。どうやら RWC への参加が授業の一環となっているみたいです。他のカンファレンスだと学生が集団で参加する場面を見たことがなく、これも新鮮な光景でした。それに加えてブースや発表でも松江高専の関係者を見かける機会が何度かあったのも記憶に残っています。

オープニングトークでは島根県知事と松江市長の挨拶のお話、レセプションには島根県観光キャラクターの「しまねっこ」が登場する場面も。

このようなイベントが続き、RWC は島根県と松江市、高専や地元の Ruby企業が一体となって作り上げている印象を受けました。
会場の外でも、タクシーの運転手やお店の方から「Rubyのイベント」で話が通じており、街全体にも Ruby への理解が溶け込んでいるようでした。プログラミング言語が町おこしの取り組みへと繋がっているのを肌で感じ取れました。

松江駅前のローソン2階にある Ruby Association の「松江オープンソースラボ」

印象に残った発表

両日共にさまざまな方面の方が発表をされていました。1日目は Ruby での現場の開発の話や RBS や ERB 、並列並列プログラミングの実装といった技術に特化した内容が多かったです。
RBS Tutorial
ERB Hacks - Speaker Deck

それに対し2日目フィヨルドブートキャンプさんによる OSS教育についてのお話や、Classi の新卒教育でたいへんお世話になった igaiga さんによる小学生支援を意識したリアルタイムアドバイスツール「RuboSensei」についての発表などがあり、プログラミング教育方面の発表が多くあった印象です。
プログラミングスクールでのOSS教育 - Speaker Deck
RuboSensei - Speaker Deck

特に印象に残ったのは2日目の松江高専の方達による「Matz葉がにロボコンで実践するSmalrubyとmruby/cを活用したプログラミング教育」でした。
小学生向けのプログラミング教育としてロボコン講習会を実施される活動を行っており、小学生の継続した学びの実現を強く意識されて取り組まれているとのこと。他の県でもロボコン講習会という取り組み自体は存在しているようで、それがご当地ロボコンと呼ばれる文化があることも初めて知りました。
参加者集めとして地域の野球チームの子供たちに声をかけたり、高専のほかにも地域コミュニティもイベント運営の手伝いをされていたりするなど、地域ぐるみでプログラミング教育に力を入れているエピソードがとても素晴らしいなと思いました。子供たちが大会で好成績を残した時は大人たちが一緒になって喜んだというほっこりする話もありました。

発表者の方が付けていた蟹の帽子も可愛らしかったです。

(発表資料は見つけられなかったのですが、例年だと12月末に全発表のアーカイブが公開されるようなので、そこで再び見ることができそうです。)

発表会場以外にも大展示場のブースでもランチセッションやスポンサーブースもゆっくり見て回りました。
ESMスーパーライトニングトーカーズ(株式会社永和システムマネジメント)さんたちによる「ESMスーパーライトニングトークス」が高速に情報密度の高いLT をバンバンやっていて迫力がありました。

ESMさんのブースにも立ち寄って Rubyメソッドアクリルキーホルダーガチャを引いてきました。String#encode 、個人的にencodeの文字の並びのバランスが好きなのでちょっと嬉しい。

まとめ

このような感じで、2日間目一杯楽しんできました。
イベント後も、懇親会で Matz氏と一緒に焼肉を食べてお話しを聞く機会があったり、ベテラン Rubyist の方からコードレビューについての考え方を熱弁していただいたりなど楽しい出来事がたくさんありました。
筆者は RubyKaigi を知って Rubyコミュニティの魅力を知りましたが、その切り口から新しいイベントに参加してみたくなった時の一つの選択肢としてぜひ RWC を考えてみてもらいたいです。

来年の5月には RubyKaigi 2024 があります。あわよくば同僚にも参加してもらって Rubyコミュニティの面白さを知ってもらいたいな……と野望を秘めつつ、この文を締めたいと思います。
読んでいただきありがとうございました!

© 2020 Classi Corp.