Classi開発者ブログ

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

個別学習機能の裏側を紹介: アダプティブラーニングエンジン「CALE」

みなさん、こんにちは。 データAI部でデータサイエンティストをしております廣田と申します。今回は、2022年5月にリリースした「全国模試に対応したAI搭載の個別学習機能」について、ユーザーからのフィードバック及びAIの部分について簡単に紹介していきたいと思います。

弊社ではこのAIを「CALE(Classi Adaptive Learning Engine)」と呼んでおり、本記事でもこちらの呼び方を使っていきたいと思います。

個別学習機能およびCALEについて

まず、個別学習機能について簡単に紹介します。こちらの画像を使いながら説明します。

個別学習機能の全体像(https://corp.classi.jp/news/2710/ より引用)

個別学習機能は

  • STEP 1: 先生によるテスト配信
  • STEP 2: 生徒によるテスト解答

の後のSTEP 3に対応する機能で、以下の流れを繰り返しながら生徒は学習していくことになります:

  • CALEが生徒の解答データを取得し分析
  • CALEが生徒におすすめの問題を複数選び、生徒に提示(推薦ロジックは後述)
  • 生徒は提示された問題の中から1つ問題を選び解答

複数の問題を提示する点が大きなポイントで、生徒の「どういう問題を解いていきたいか」といった意志を反映する余地を確保することで、生徒が納得感を持って取り組めるよう工夫しています。

CALEはこの個別学習機能の中で、生徒に合った問題を推薦する役割を担っています。

ユーザーからのフィードバック

幸いなことに、リリース以後多くの生徒にCALEを利用していただいております。ここでは、CALEを利用していただいた一部の学校の生徒に対して実施したアンケート結果について紹介いたします。

CALEが推薦した問題の難易度について尋ねたところ、一番多いのが「難しかった」で、次点で「ちょうど良かった」となりました。「難しかった」と「ちょうど良かった」には大きな差は無く、総評としては「やや難しい」と言えると思います。

CALEが推薦した問題の難易度についてのアンケート結果

CALEの問題推薦ロジックの基本アイデア

ここではCALEが問題を推薦する際の基本的なアイデアについて紹介します。ポイントは

  • ①問題の難易度調整
  • ②学習単元の遷移

の2点です。

①問題の難易度調整

基本的なアイデアは「難しすぎず易しすぎない問題を推薦する」というものです。易しすぎる問題では学べることも少ないですし、難しすぎる問題の場合はそもそも何かを学び取ることが難しいです。CALEでは「この生徒にこの問題を出した時、何%の確率で正解できそうか」を計算し、その予測正答確率の値に基づき、難しすぎず易しすぎないような問題を選び出しています。予測正答確率の算出には項目反応理論(Item Response Theory)を利用しております。

既に触れましたが、現状のCALEは生徒からは「やや難しい」と評価されております。仮に正答率が高めの問題を選ぶようにCALEの挙動を調整すれば、生徒が「ちょうど良い」と感じる割合が増えると予想されます。しかし単純に難易度を易しくしてしまうと、今度は生徒の学びに繋がる要素が減ることが予想されます。生徒の学習の進めやすさと学習効果、双方のバランスがちょうど良くなるラインを探ることは非常に興味深いテーマで、今も検討を重ねているところです。

②学習単元の遷移

上記のような難易度調整をしても全く問題に正解できないケースが存在します。例えば、現在学習中の単元の前提の単元の理解が不足しており、現状の単元の理解が進まないようなケースです。このようなケースの対応策として、CALEでは学習単元の遷移機能も備えております。

生徒が学習中の単元で全く正解できなかった場合、CALEはその前提知識に相当する単元にさかのぼって問題を選んでくるようになっております。もしさかのぼり先の単元の理解が十分だと判断されれば、元の単元から出題されるようになります。

ただし単元間の依存関係は自明なものではないため、「この単元ができなかった時にどの単元にさかのぼらせるべきか」といった点については今も検討しているところです。

今後の課題

課題は山積みです。例えば現状のCALEの問題推薦ロジックについての課題であれば

  • 出題難易度のバランス調整
  • さかのぼり先の単元の選択方法
  • 予測正答確率の精度向上

などが挙げられますし、さらに大きな枠組みで考えると

  • 現状のロジック以外の問題推薦ロジックの検討
  • 学習効果の測定

などが挙げられます。

学校の先生・生徒の声に耳を傾け、教育工学の知見も踏まえ、1つ1つ着実に解決していきたいと考えております!

さいごに

今回紹介したCALEをはじめとして、弊社ではAI系の機能の研究開発を積極的に行なっております。まだまだ課題が山積みなCALEを一緒に進化させていきたい、教育現場の課題を技術で解決したい、などの気持ちがある方はぜひこちらからご連絡ください!

Rubyの会社の手札にGoを加えるまで

こんにちは・こんばんは・おはようございます、System Platform領域所属のid:aerealです。

前回このブログに記事を書いた時の所属から変わり、チームトポロジーを参考に編成された新たな領域でチームを横断した関心事に取り組んでいます。

当社ClassiはRubyを主要な開発言語として採用し、各種Ruby関連のイベントに参加・協賛するなど「Rubyの会社」という印象を持たれているのではないでしょうか。

実際、今もClassiの屋台骨を支えているシステムはRubyで書かれていますし、今後1〜2年でそれが大きく覆ることは考えにくいでしょう。

そんなClassiに新たにGoという選択肢を有力なものとして加えるための過程と取り組みについて紹介します。

Classiの技術選定と手札たる要件

Classiの技術選定に対するスタンスと題したVPoTの丸山が書いた記事にあるように、この記事を執筆している現在は「この技術領域にはこれを使ってね」といった強い制約はありません。

一方で先の記事でもやる気のあるメンバーがめっちゃ新しい技術スタックでキラキラした開発をおこなったけど、それをメンテ、運用できる知見は誰ももっていないあるいはだれにも継承されず、あとには触るに触れないコンポーネントが残った……と触れられているように、闇雲に採用技術を発散させることを一概によしとできません。

このトピックに対し私なりに出した答えが社内コミュニティができて盛り上がっている状態になれば、技術選択の第一候補として置いてよいだろうというものです。

そもそもスキルや知識を持った開発者が存在しさえすれば良いのなら「新しい技術スタックが採用されたが、次第に腫れ物になってしまった」という出来事は起きないはずです。 たとえ提唱者が退職その他の理由で離れてしまったとしても、採用や育成をすればカバーできるはずです。

そこで私は「触るに触れないコンポーネント」ができあがってしまう力学に目を向けました。 そこからClassiの事例や自身の経験に照らして、なぜ触れない・触りたくないと思うかを考えると新しい技術に触れるリスクや恐怖が期待されるおもしろさやメリットを上回って見えるからだという仮説を導きました。

我々ソフトウェアエンジニアはプロダクト開発を通して顧客に価値を届けることが大前提で、他に求められる種々の資質などは細分化された結果のものです。

価値の中には提供速度や信頼性・安定稼働といった要素も当然含まれます。これらを満たすため、新しい技術を採用したコンポーネントを保守しつづけることを諦め、より廃止ないし・よりコンサバな技術セットで作り直すという判断に至るのでしょう。

またリスクや恐怖がおもしろさやメリットを上回って 見える ということも肝だと考えます。

人間は未知に対する恐怖心を抱えがちです。栄枯盛衰の激しい業界に身を置く我々Webエンジニアであっても多かれ少なかれそれは避けられないはずで、恐怖心は認知バイアスを呼びます。

認知バイアスがかかった状態では望ましい判断を選びにくくなってしまいます。

冷静かつ丁寧に事実を集め評価すれば悪くない選択肢だったとしても、負の側面が大きく見えるのなら「失敗だった」という判断になりえます。

またバイアスだけではなく感情的な要素も無視できないでしょう。「新しい技術を試行錯誤するおもしろさに立ち会えず、ただ尻拭いをさせられている」という意識は事実や理屈でそう覆せるものではありません。

これらのことから特定の誰かではなく社内でうっすらと盛り上がっている・ムーブメントが起きているという状況を作ることができたらもはやその新しい技術は未知のものではなく「自分はよく知らないけど社内ではよく聞くアレ」になります。

また、そういった状況を作ることができれば提唱者個人の負荷も減るでしょう。

取り組み

実際にGoの社内コミュニティを作るためにやってきた取り組みを紹介します。

Slackチャンネル

何はなくともまずSlackチャンネルです。5年以上前に作られたけど過疎ってarchiveされたチャンネルを掘り起こし #go-zassou として復活させました。

とはいえunarchiveしたからすべて良しとは当然なりませんので、まずは自分がGo関連のニュースや記事を紹介することから始めました。

おもしろそうなライブラリを紹介したり、カンファレンスの発表資料を紹介したり、内容は様々です。 とにかくチャンネルに人がいる感を高めるために「こういうところがおもしろそう」というコメントを添えて隔日〜週数回ペースを当初保ちました。

また社内でGoを採用したコンポーネントの開発に関する相談や質問は積極的に他のチャンネルからこちらに誘導しました。

たいへん地味でしたが、ここ数ヶ月では自分以外のメンバーが興味をもったニュースや記事を紹介してくれたり、自発的に質問してくれるようになったので、archiveされて存在がなかったころに比べたら盛り上がっていると言ってよいのではないでしょうか。

もくもく会、A Tour of Goをやる会

筆者以外はGo初学者であったり過去に趣味で触っていたけれど今はご無沙汰しているといったメンバーが多いです。

そんなメンバーのキャッチアップを盛り上げるためにA Tour of Goをやる会を不定期で開催しました。

それぞれ進度は異なるので画面共有して同時に進行したりはせず各々で進める、いわゆるもくもく会形式をとりました。

気軽に質問できる環境を提供することが目的で「そういう会があるなら」と参加してくれたメンバーも一定いたので成功したといえるのではないでしょうか。

ライブコーディング

ライブコーディングに参加することで、細かいエディタやツールの使い方からいわゆるGoらしい書き方・設計の勘所など情報量の多いインプットを得ることができるでしょう。

基礎知識を得た状態から次に実践的な開発に入る上でライブコーディングは広く満遍なくとっかかりを得るのに適しています。

またライブコーディングに参加できなかったメンバーにはGoogle Meetの録画機能を使って動画も共有しています。

野良レビュー

私個人が開発に関わっていないGoを採用したコンポーネントも登場してきたので、一通りのコードリーディングをしてコミットも見ています。

本番提供するサービスとしてより望ましい・より高い品質を狙える点はたくさんありますし、それらを一度にくまなく説明しきることは難しいので、ベストエフェートで発見次第、Issueを立てたりPull Requestを送っています。

カロリーの高い取り組みなので新しい言語や技術を導入する際にはこれくらいやるべきとは言えませんが、個人的にいろんなコードを読むのが好きな性質なので自分の楽しみになる上に実利も備えられてラッキーくらいの気持ちでやっています。

成果

  • 本番投入サービス開発経験: 1人→5人
  • 本番投入サービス: 1つ→3つ

数字でみるとこのような結果になりました。

また嬉しいことにサービス開発を通じてライブラリを作ってくれたメンバーも現れました。

https://github.com/minhquang4334/goapartment

マルチテナント構成のDBに対しテナントに応じた接続を提供するライブラリで、ちょうどマルチテナントDBを擁するClassiにとって今後必要になることが明らかだったので、そこにチャレンジしてくれたことと品質に驚き感謝しています。

今後

まだまだ精力的に盛り上げているのは私個人なので、私がClassiからいなくなってもGoの新規採用が続く状況になったとはいえないでしょう。

ただ、既にGoを採用したコンポーネントのメンテナンスに意欲を向けてくれる状況にはなっているのではないでしょうか。

実際にそういったシチュエーションを迎えていないので評価は難しいのですが、実際に使う・読み書きするというコンテキストでGoの話題を口にしているメンバーは増えたので良い方向に変わっていると信じています。

研修を終えて勉強になったこと

自己紹介

初めまして、エンジニアのmanhnguyen1998です。
2021年秋にベトナムからClassi株式会社に入社してから半年程新卒研修を受けました。
今回、研修で印象に残ったことについて振り返ります。

研修でやったこと

以下の内容の研修を受けました

日本語研修

仕事で最も重要なのはコミュニケーションです。意思伝達ができないと仕事もうまくできないと思います。そのため、研修で以下の内容を重視しました

日本文化の理解

  • 『クローズアップ日本事情15』という本を読みました。文化を学ぶことでコミュニケーションが取りやすくなると思います。また、文化を学んだ時は先生と一緒に相談し、説明能力も高められたと思いました

ビジネス知識

  • IT知識だけじゃなくて、ビジネスの知識も必要です
  • 色々な会社のリーダーのインタビュー記事を読んで日本の会社でどのようなことをやっているかを知り、考え方などを学びました

単語の学習

  • 仕事でよく使っている語彙がいっぱいあるので、毎日新しい語彙を勉強した方がいいと思います
  • esa、Slackで職場の語彙、表現を集め、これらがどのような場面で使われているのか学んだり、似ている語彙を探したりして学習しました

コミュニケーション以外の能力も高めたいと思いましたので、日本語能力試験(JLPT) N1レベルの学習もしました

  • 語彙
  • 文法
  • 模擬試験

Railsチュートリアル

ClassiのサービスはRailsを使っていますので、Railsチュートリアルで基本的な知識を勉強しました

  • Railsチュートリアルを進めました
    • 資料、動画を見ながらコーディングしました
  • 学んだ知識を言葉で説明するRailsチュートリアルトレーニングを受けました
    • YassLab社の安川さんのフィードバック会に参加しました
    • Railsチュートリアルやトレーニングの内容に関して理解を深めることができました。例えば、なぜRailsでStrongParameterを使うのかを聞いて、脆弱性を防ぐために使われていることを把握できて、自分の疑問も解消できました

PBL研修

  • チュートリアルの後、Railsチュートリアルのコードに機能追加するのを通じて、実際に自分で考えながら手を動かしてRailsのコードを書く練習をしました
    • Gitの使い方、Pull Requestの作り方を学びました
    • DB設計、Modelingを学びました
  • PBLの成果、学んだことは発表会を行って、プレゼンしました
    • 追加機能として成果があります。今回追加したのはリアクション機能です
      PBLでリアクション機能を作った

セキュリティ研修

  • ただコーディングするだけではなくて、セキュリティの脆弱性を防ぐ方法を学ぶことも大切なので、セキュリティ研修を受けました

Docker研修

  • Classiでは一部サービスがECS化されていることもあり、研修を受けました

フロントエンド研修

  • バックエンドと同じように、フロントエンドも大切で基本的な知識を勉強しました
    • HTML ・CSS・JavaScript・Angularの基本知識を学びました
    • 学んだ知識を生かしてフロントエンドコーディングをしました
  • フロントエンド研修が終わった後、発表会を行いました。プレゼンテーションしました
  • 今回、Angularを使ってFoodReviewのサイトを作りたいので、画面のレイアウトを変更しようと思いました
    FoodReviewのレイアウト

AWS研修

  • ClassiはAWSを使っていますので、そのサービスの使い方を勉強するのによい経験でした

OJT

  • 社内プロダクトのRails API実装タスクを担当しました
  • 実際に本番で使われているコードを通じて、手を動かし、どんな風に開発するかの流れを学びました
  • 長い時間だけど、いろいろなことを勉強できるし、卒業式で振り返ることができて嬉しかったです!

研修を通して学んだこと

目標は何か、そのために何をするべきかは大切

研修の目標は学習なので、タスクを達成、早く完了することではありません。タスクを早く完了できても、何も学習できなければ、その研修の目標は達成できません。そのため、早く完成させるのではなく、自分が何をどういった目的で学んでいるのかをずっと頭の中で考えながらタスクを進めました。例えば、コードを読んだ時、分からないところをちゃんと把握しメモを取っておくようにしました。時間はかかっていますが、メモした内容で、後で検索したり調べたりするようにして、疑問が解消できて勉強の目的が達成できました。今もこれからも、目標に基づいて計画を調整し、自分のやることを変更することは大切です。

目的は大事なこと

コミュニケーションも大切

ベトナム人として、言語も文化も、働き方や考え方も多少異なるところがあります。そのため、仕事の最中にミスをしたりわからないことが出てくることもあります。困ったらどうするべきか?このやり方が正しいのかをよく考えています。その時、コミュニケーションスキル、質問スキルはもっとも大切です。それらがあることでチームメンバーと情報共有することができるようになります。

コミュニケーションで基本的なことは

まとめるのも大切

研修で色々学びましたが、時間が経つと勉強した知識とやり方を忘れるかもしれません。だからこそ、何をやったか、何を学んだか、ちゃんとまとめて後で見返すのは良いと思います。esaを使ってメモをしていると、今後に役に立ちます。例えば、以前同じエラーを解決したことがあるものの、どうやったのかは忘れてしまったというときもあるでしょう。メモがあるとすぐに解決できると思います。また、自分も他の人もそのメモから学習し、問題が解決できるようになります。

どうやってメモするか

  • 具体的には、
    • esaで自己メモを書く
    • メモの書いた目的、問題、解決方法があると良いと思います
    • コード例、説明の画像があると分かりやすい
      esaにメモしたこと

最後に

入社してから半年程研修をやったおかげで、配属後もスムーズに業務に入ることができました。

例えば、チームでコミュニケーションができて作業の内容をちゃんと理解したり、RailsもAngularの知識もタスクをやる最中に活用できたりしました。まだ全部の研修で勉強した内容は使えていませんが、これから使う機会がありそうですし、そのときは役に立つと思います。

研修できたのは皆さんのおかげで、カリキュラムもサポートも受けて学習できました。 これからも良いプロダクトを作って貢献していきます!ありがとうございました!

ISUCON12予選 スコア4位相当でしたが失格になりました

TL;DR

こんにちは。Classi開発部のminhquang4334です。 今年は開発支援部のhenchiyb先輩と一緒に 2回目でyasuoチームとして ISUCON12の予選に参加しました (参考: 1回目で参加したブログ)。

最終結果は予選通過スコアを超えて、 4位/700チーム相当でしたが、SecurityGroupの TCP:8080 ポートがオープンされていたため、レギュレーションに引っ掛かって失敗しました。

以下のチームは予選通過スコアを記録していましたが、追試において失格となっています。

  • yasuo

    • 環境チェックにおいて、SecurityGroupの TCP:8080 ポートがオープンされていた

このブログでは積極的に自分の感想やチームがやったことを共有したいと思っています。

全体的な感想

正直、悲しい気持ち半分、嬉しい気持ち半分で戸惑っています。予選の実施前には、ここまでスコアを伸ばして、上位のチームと並べるのが想像できなかったので、競技中にリーダボードでずっと1位や2位などになっているのがとても嬉しかったです。しかし、そこまでやったのに、夢でも考えられないポート公開したミスで、失格となったことはとても悔しい気持ちでした。

失格という結果だけみると、大失敗のようですが、私がISUCONに参加した目的は自己成長で、昨年参加した時の結果である100位から今年の上位のチームになるまで学びになったことは数多くあるため、大成功だと考えています。

体制

今回チームは二人の体制で予選に参加しました。

  • アプリケーション担当: minhquang4334
    • 役割: ファシリテーターとしてアプリケーションとデータベース周りの改善を進める
  • インフラ担当: henchiyb
    • 役割: インフラ設定やチューニングやスクリプト作成など

二人は同じビルに住んでいますが、Meet経由でお互いに画面を共有しながら、作業を行いました。チャットはSlackを使いました。

使用したツール

  • mysqldumpslow
  • alp
  • top
  • pprof
    • プロファイリングを見るため、TCP:8080 ポートを公開したので、失敗になった原因だ
  • VSCode

チームの方針

予選に参加する前に、チーム内のアグリーメントを決めました。

  • 実装言語はGoにする
    • 予選までアプリケーション担当の自分の経験は6ヶ月しかないが、社内のGoベテランから色々教わっていたため
  • 自動のスクリプト使用する
    • Gitにコミットしてからなるべく早くリリースできるようにスクリプトを工夫した
    • モニタリング結果の取得でも一括取得できるためのスクリプトなど
  • 複数選択肢がある場合、ロジック修正が必要ない選択肢を優先する
    • ロジック修正はミスになりやすいし、デバッグ時間もかかる
    • 例として、N+1の問題であれば、N+1を解消するより、キャッシュの方が簡単にできたら、キュッシュする
  • チームなりのベストプラクティス資料を準備して、それ以外に経験がない技術ならやらない
    • たった8時間の競技で新しい技術を試したら、リスクが高いし、失敗したら精神も落ちる
  • モニタリング結果を心から信じて、それに基づいて改善する
    • 推測するな、計測しよう
    • topやalpやslowlogの結果などを斜めてから、改善の優先順位を判断して進める

当日チームのリポジトリと関連な情報

最終構成

  • サーバー 1つ目: Nginx
  • サーバー 2つ目: アプリケーション + SQLite
  • サーバー 3つ目: MySQL

結果

リーダボード

最大スコアは 75209点で、最終スコアは 59911点です。

競技終了時間のリーダボード

スコア変動

15時ごろ予選通過スコアを超えました。

yasuoチームの競技中のスコア変動グラフ

やったこと

10:32 最初のモニタリング結果 (スコア: 3768)

運営チームから準備してくれたCloud FormationをAWSに適用したり、インスタンスにアクセスしたり、gitやalpやslowログを設定したりするのが30分ぐらいかかりました。10:32ごろに初めてモニタリング結果を出力しながら、ベンチマーカーを実行できました。

10:46 Visit HistoryテーブルにIndexを適用する (スコア: 4920)

モニタリング結果をみたら、すぐにMysqlのCPU利用率が高すぎることや遅いクエリがあると見えました。

# top
   PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND                                                                                  
   4883 mysql     20   0 2569292 528048  34932 S  89.0  13.9   2:27.14 mysqld 

# slowログ

Count: 2106  Time=0.05s (106s)  Lock=0.00s (0s)  Rows=83.3 (175390), isucon[isucon]@localhost
  SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = N AND competition_id = 'S' GROUP BY player_id

MysqlのCPU利用率を減らしないと前に進められないため、Indexを追加しました。以下のIndexを追加した結果、1千点ぐらいのスコア伸ばしました。

CREATE INDEX `visit_history_id_idx` ON visit_history (`tenant_id`, `competition_id`, `player_id`);

このvisit_historyテーブルは初期データでは32万ぐらいのレコードがあるため、Indexの作成時間もかなりかかりました。

10:57 いつもの設定 (スコア: 5094)

  • いつもと同じ設定を追加して、少しスコアが増えました。
    • MySQLに接続するオプションのinterpolateParams=trueに設定する
    • アプリケーションのDebugモードを外して、e.Use(middleware.Logger())をコメントアウトする

12:04 Nginxとアプリケーションの様々なチューニングを行う (スコア: 9955)

11:00からの一時間ぐらいでいくつかの修正を行いました。

  • Staticファイル (.css, .jsなど)をNginxに任せて配信したり、Expiresを設定したりする
  • playerテーブルとcompetitionテーブルはほぼInsertで、Updateがあまりないため、Goでオンメモリにした
  • competitionScoreHandlerメソッドのplayer_scoreのデータをCSVから挿入する処理は N+1になっているので、Bulk Insert化した

チューニングした結果、2倍近くスコアを伸ばしました

12:14 MySQLでID Generatorをやめる (スコア: 11884)

予選のアプリはマルチテナントの仕組みで、複数のテナントデータベースがあるが、テナントのテーブルのIDを発行する処理は共通データベースに任せる仕組みです。

// システム全体で一意なIDを生成する
func dispenseID(ctx context.Context) (string, error) {
//省略
ret, err := adminDB.ExecContext(ctx, "REPLACE INTO id_generator (stub) VALUES (?);", "a")
//省略

id_generatorテーブルの構成ですが、PKのIDカラムが AUTO_INCREMENTと設定されて、順番的にIDを生成する必要があるため、高負荷のシステムに適切ではない仕組みです (参考: MySQL AUTO_INCREMENT ハンドリング)。なので、その仕組みをやめて、Goのxidを採用して、アプリケーション側で一意なIDを発行できるように修正しました。

// システム全体で一意なIDを生成する
func dispenseID(ctx context.Context) (string, error) {
  guid := xid.New()
  return fmt.Sprintf("%x", guid.String()), nil
}

この修正を反映して、ベンチマーカーを実行したら、初めて1万点を超えるチームになりました。リーダーボードでも一位になったことを気づいて、自信を持てました。

12時ごろから初めて1万点を超えるチームになりました

12:36 playerの最新スコアを latest_player_scoreで保存できるようにSQLiteのトリガーを工夫する (スコア: 12472)

alpを確認したら、/api/player/player/:player_id/api/player/competition/:competition_id/rankingは遅いエンドポイントなので、なんとかしないとなあと思いました。

+-------+-----+------+-----+-----+-----+--------+---------------------------------------+-------+--------+----------+-------+-------+-------+--------+--------+-----------+------------+--------------+-----------+
| COUNT | 1XX | 2XX  | 3XX | 4XX | 5XX | METHOD |                  URI                  |  MIN  |  MAX   |   SUM    |  AVG  |  P90  |  P95  |  P99   | STDDEV | MIN(BODY) | MAX(BODY)  |  SUM(BODY)   | AVG(BODY) |
+-------+-----+------+-----+-----+-----+--------+---------------------------------------+-------+--------+----------+-------+-------+-------+--------+--------+-----------+------------+--------------+-----------+
|  5029 |   0 | 4977 |   0 |  52 |   0 | GET    | /api/player/player/                   | 0.008 | 22.848 | 1283.200 | 0.255 | 0.360 | 1.624 |  3.768 |  0.982 |     0.000 |   2124.000 |  5922926.000 |  1177.754 |
|  2823 |   0 | 2790 |   0 |  33 |   0 | GET    | /api/player/competition/              | 0.004 | 22.316 |  585.556 | 0.207 | 0.224 | 0.680 |  3.528 |  1.039 |     0.000 |  12265.000 | 26362948.000 |  9338.628 |

両方のAPIはテナントデータベースのplayer_scoreテーブルに触って、playerの最新スコアを取得しています。player_scoreテーブルはplayerのベンチマーカーの実行回数とスコアを履歴として保存するテーブルです。なので、playerの最新スコアがあるレコードだけは別のテーブルで保存した方がより効率になると思って、latest_player_scoreテーブルを作成しました。player_scoreにレコード追加・更新が発生したら、トリガー経由でlatest_player_scoreも追加・更新できるように設定しました。SQLiteのトリガーは書いたことがないですが、無事に以下のように実装できました。

CREATE TABLE latest_player_score (
  id VARCHAR(255) NOT NULL,
  tenant_id BIGINT NOT NULL,
  player_id VARCHAR(255) NOT NULL,
  competition_id VARCHAR(255) NOT NULL,
  score BIGINT NOT NULL,
  row_num BIGINT NOT NULL,
  PRIMARY KEY(tenant_id, competition_id, player_id)
);

CREATE TRIGGER IF NOT EXISTS tr1 AFTER INSERT ON player_score
BEGIN
  INSERT OR REPLACE INTO latest_player_score(id, tenant_id, player_id, competition_id, score, row_num) VALUES(NEW.id, NEW.tenant_id, NEW.player_id, NEW.competition_id, NEW.score, NEW.row_num);
END;

それにより、アプリケーション側でplayerの最新スコアを取得する処理はlatest_player_scoreでクエリできるように修正して、不要なORDER BYも外せました。

-- 修正前
SELECT * FROM player_score WHERE tenant_id = ? AND competition_id = ? AND player_id = ? ORDER BY row_num DESC LIMIT 1

-- 修正後
SELECT * FROM latest_player_score WHERE tenant_id = ? AND competition_id = ? AND player_id = ? LIMIT 1

14:12 N+1解消とsort.Sliceを外す (スコア: 15175)

  • /api/player/player/:player_idではlatest_player_scoreでクエリした後、まだN+1の問題が残っているので、解消した
  • /api/player/competition/:competition_id/ranking ではplayerのランキングを取得する処理はGoのsort.Slice処理などをやめて、全部クエリに任せた
SELECT * FROM latest_player_score WHERE tenant_id = ? AND competition_id = ? ORDER BY score DESC, row_num ASC LIMIT ?

14:31 /api/organizer/players/add のBulk Insert 化 (スコア: 18581)

/api/organizer/players/addでplayerを追加する処理が N+1なので、Bulk Insert化しました。

14:55 終了した大会の請求金額計算をキャッシュ (スコア: 22705)

alpを確認したら、請求金額計算APIが遅くて、15s以上かかるAPIがありました。

+-------+-----+------+-----+-----+-----+--------+---------------------------------------+-------+--------+---------+-------+-------+--------+--------+--------+-----------+------------+--------------+-----------+
| COUNT | 1XX | 2XX  | 3XX | 4XX | 5XX | METHOD |                  URI                  |  MIN  |  MAX   |   SUM   |  AVG  |  P90  |  P95   |  P99   | STDDEV | MIN(BODY) | MAX(BODY)  |  SUM(BODY)   | AVG(BODY) |
+-------+-----+------+-----+-----+-----+--------+---------------------------------------+-------+--------+---------+-------+-------+--------+--------+--------+-----------+------------+--------------+-----------+
|    36 |   0 |   34 |   0 |   2 |   0 | GET    | /api/admin/tenants/billing?before=xxx | 0.004 | 16.256 |  84.792 | 2.355 | 3.400 | 14.224 | 16.256 |  3.246 |     0.000 |    988.000 |    31387.000 |   871.861 |
|    90 |   0 |   89 |   0 |   1 |   0 | GET    | /api/organizer/billing                | 0.004 | 20.320 |  40.444 | 0.449 | 0.556 |  0.836 | 20.320 |  2.266 |     0.000 |   6489.000 |   260072.000 |  2889.689 |

slowログでも 一番遅いクエリも請求金額計算処理にあるクエリです。

Count: 5500  Time=0.01s (50s)  Lock=0.00s (0s)  Rows=135.9 (747469), isucon[isucon]@localhost
  SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = N AND competition_id = 'S' GROUP BY player_id

コードを読んだら、大会が終了している場合のみ請求金額が確定するので、終了している大会の請求金額をキャッシュした方が良いではないかと仮説を立てました。

func billingReportByCompetition(ctx context.Context, tenantDB dbOrTx, tenantID int64, competitonID string) (*BillingReport, error) {
  cachedKey := fmt.Sprintf("%d_%s", tenantID, competitonID)
  if comp.FinishedAt.Valid {
      if cached, ok := cachedFinishedBillingReport.Load(cachedKey); ok {
          result := cached.(BillingReport)
          return result, nil
      }
  }
  // 省略
  result := &BillingReport{..}
  cachedFinishedBillingReport.Store(cachedKey, result)
  return result, nil

キャッシュした後、エラーはほぼなく実行できました。alpをみたら、請求金額計算のAPIの負荷が一気に減らせたと確認できました。

+-------+-----+-------+-----+-----+-----+--------+---------------------------------------+-------+--------+---------+-------+-------+-------+--------+--------+-----------+------------+--------------+-----------+
| COUNT | 1XX |  2XX  | 3XX | 4XX | 5XX | METHOD |                  URI                  |  MIN  |  MAX   |   SUM   |  AVG  |  P90  |  P95  |  P99   | STDDEV | MIN(BODY) | MAX(BODY)  |  SUM(BODY)   | AVG(BODY) |
+-------+-----+-------+-----+-----+-----+--------+---------------------------------------+-------+--------+---------+-------+-------+-------+--------+--------+-----------+------------+--------------+-----------+
|    56 |   0 |    55 |   0 |   1 |   0 | GET    | /api/admin/tenants/billing?before=xxx | 0.024 | 11.284 |  74.860 | 1.337 | 2.128 | 7.032 | 11.284 |  2.054 |     0.000 |    999.000 |    49014.000 |   875.250 |
|   112 |   0 |   112 |   0 |   0 |   0 | GET    | /api/organizer/billing                | 0.004 | 10.556 |  30.120 | 0.269 | 0.060 | 0.108 |  8.684 |  1.506 |   242.000 |   6810.000 |   315668.000 |  2818.464 |

請求金額計算処理にある遅いクエリも呼ばれる回数は5500回から3779回まで抑えました。

Count: 3779  Time=0.01s (46s)  Lock=0.00s (0s)  Rows=179.0 (676478), isucon[isucon]@localhost
  SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = N AND competition_id = 'S' GROUP BY player_id

更なる改善ですが、大会を終了する時点からすぐ非同期で請求金額計算処理を走らせた方が効率的ではないかと思って、大会が終了させるcompetitionFinishHandlerのメソッドで、非同期で請求金額計算のキャッシュを更新できるように修正しました。

func competitionFinishHandler(c echo.Context) error {
  // 省略
  // 大会を終了させる処理が終わったら
  go billingReportByCompetition(ctx, tenantDB, v.tenantID, id)

15:40 MySQLを3つ目のサーバーに設定した (スコア: 〜25000)

MySQLは3つ目のサーバーに移動してみた結果、少しスコアだけが増えました。例年なら、スコアが急増するという記憶があったので、あまりスコアが伸びず残念でした。

15:56 Fileでロックする方法をやめて、Golangのsync.Mutexにした (スコア: 31452)

テナントデータベースでクエリする時に、トランザクションをかけるために、ロックファイルを作成するという仕組みがあると気づけました。

// 排他ロックする
func flockByTenantID(tenantID int64) (io.Closer, error) {
    p := lockFilePath(tenantID)

    fl := flock.New(p)
    if err := fl.Lock(); err != nil {
        return nil, fmt.Errorf("error flock.Lock: path=%s, %w", p, err)
    }
    return fl, nil
}

わざわざファイルを作成して、ファイルロックを取ることは非効率ではないかと思って、Goのsync.Mutexに変更してみたら、点数が急増しました。それぞれテナントごとにsync.Mutexのインスタンスが必要なので、sync.Mapを合わせて実装しました。一回目の実装で不具合なく無事に動いたので今大会の自分の一つのハイライトでした。

   // / DELETEしたタイミングで参照が来ると空っぽのランキングになるのでロックする
    // fl, err := flockByTenantID(v.tenantID)
    // if err != nil {
    //     return fmt.Errorf("error flockByTenantID: %w", err)
    // }
    // defer fl.Close()
    var lock sync.Mutex
    if cached, ok := mapTenantLock.Load(v.tenantID); !ok {
        lock = sync.Mutex{}
        mapTenantLock.Store(v.tenantID, lock)
    } else {
        lock = cached.(sync.Mutex)
    }

    lock.Lock()
    defer lock.Unlock()

16:06 既存の100個のテナントデータベースにIndexをまだ適用していないと気づいたので、修正した (スコア: 37056)

SQLiteで動いているマルチテナントデータベースのクエリslowログはどうすれば監視できるのかものすごく悩みました。アプリケーションで発行するクエリをみて、Indexを追加したのですが、全部のテナントで機能しているか分かりませんでした。調べてみたら、やっばり 10_schema.sqlで追加Indexを定義しても、既存の100個のテナントDBに影響がないと気づいて、initializeの時点で、全てのテナントにIndexを適用できるように修正しました。

16:52 請求金額計算処理で重いクエリを2sでキャッシュして、sqlx.DBのMax Connsを50まで増やした (スコア: 〜60000)

請求金額計算処理はリアルタイムで反映しなくてもいいかもしれないとマニュアルを読みつつ気づきました。それで、slowログの一番遅いクエリのランキングにアクセスした参加者のIDを取得するクエリを2sでキャッシュしてみました。

visitCacheKey := fmt.Sprintf("%s_%d", tenantID, comp.ID)
now := time.Now()
if cachedTime, ok := cacheVisitHistoryTimeAt.Load(visitCacheKey); ok {
    if cached, ok := cacheVisitHistory.Load(visitCacheKey); ok && now.Sub(cachedTime.(time.Time)) < 2*time.Second {
        vhs = cached.([]VisitHistorySummaryRow)
    }
} else {
    if err := adminDB.SelectContext(
        ctx,
        &vhs,
        "SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = ? AND competition_id = ? GROUP BY player_id",
        tenantID,
        comp.ID,
    ); err != nil && err != sql.ErrNoRows {
        return nil, fmt.Errorf("error Select visit_history: tenantID=%d, competitionID=%s, %w", tenantID, comp.ID, err)
    }
    cacheVisitHistoryTimeAt.Store(visitCacheKey, now)
    cacheVisitHistory.Store(visitCacheKey, vhs)
}

ベンチマーカーを実行したところ、「へ〜」、「すごい〜」、「マージか〜」という感じでスコアとモニタリング結果を確認しました。キャッシュした一番重いクエリの実行回数が一気に減らせるため、MySQLサーバーのCPU利用率は100%ぐらいから安定に20%で動いていました。逆にアプリケーションのCPUが150~170%までピークになっていて、「大量のリクエストが来るのではないか」と思って、スコアも一気に伸ばしました。この実装は正直GoのSingleFlightを使った方が良いと思いますが、時間があれば修正したいけど、結果がいい感じなのでそのままにしました。

MySQLサーバーのCPU利用率はものすごく低くなったので、sqlx.DBのConnection Poolingを有効にするための設定を行いました。色々試した結果、50で行くと決めました。

adminDB.SetMaxOpenConns(50)
adminDB.SetMaxIdleConns(50)

17:02 Appを2つ目のサーバーに移動して、最終構成を固めた (スコア: 〜75000)

アプリケーションのCPU利用率が高すぎて、限界になってしまうので、どうしてもスコア伸ばせないと思いました。 CPU利用率を減らすため、いくつかのAPIを2つ目のサーバーに分散できないかいろいろ調べましたが、SQLiteの制約でやっばり今の技術スタックでは不可能だと分かりました。それで、APIを分散しないで、システムレイヤを分けました。1つ目のサーバーはNginxにして、アプリケーション自体は全て3つ目のサーバーに移動しようと決めました。その結果、チームの最終の構成で、当日の最大スコアの〜75000点 (+86000点、 -11000点) に到達できました。

17:52 競技終了まで挑戦したが、最終スコア: 59911ぐらいだった

最後まで、alpで一番時間がかかる/api/player/player/:player_id/api/player/competition/:competition_id/ranking にはなんとかしたいと工夫したが、どれでも改善できなさそうなので、諦めました。 17:35ぐらいで、掃除して、再起動試験を行いました。何度もベンチマーカーを実行した結果はスコアは安定せず、60000〜72000ぐらいでしたが、マニュアルを確認したら、再現スコアが最終スコアの85%以下の場合は失格 というルールがあると気づいたので、6万ぐらいで全然本戦に出られると思うので、安全のため、最終スコアは6万のままにしました。

最後に

今年の予選の問題は上手く作られていたと思っています。一番の謎を解いたら、点数が一気に伸ばせるという問題じゃなくて、小さい改善を積み重ねて、こまめにリリースしていくことでスコアがどんどん高くなると感じました。ISUCON12の運営の皆様がいい感じなIsuconを開催してくださって、感謝してもしきれません。

yasuoチームは今年でポート公開したミスで本戦に出られないのがすごく悔しいですが、まだまだ本戦で挑戦したい気持ちがあるので、また来年楽しみに参加しようと思います。

以上です。

データ基盤の品質向上への取り組み

こんにちは、データエンジニアの石井です。 先日公開した記事「社内向けのデータ基盤から集計結果をReverse ETLしてサービスに組み込んだ話」で、ダッシュボード機能のリリースにより、Classiのデータ基盤が「社内用データ基盤」から「ユーザー影響あるシステムの一部」へ進化した話をしました。「ユーザー影響あるシステムの一部」への進化に伴い、データ基盤の品質担保は必要不可欠です。今回は、データ基盤の品質向上に取り組んだKANTプロジェクトについてご紹介します。

KANTプロジェクト

背景・課題

Classiのデータ基盤がユーザー影響あるシステムの一部になる前、つまり社内用データ基盤だった頃には以下のような課題がありました。

  • データ基盤の状態把握
    • マルチクラウドにおけるデータ基盤全体の状態把握ができていなかった
    • データ基盤の実行状態(SUCCESS, FAIL, RUNNINGなど)の把握が、大量にSlack通知されているのみで全体像をつかめていなかった
    • 結果として、データ連携がうまくできていなかったことが社内ユーザーからの連絡でわかったり、気づかないまま放置されたりすることがあった
  • データ基盤の安定性
    • 「データ基盤が安定している」という状態を明確に「定義」できていなかった
    • それゆえ、定義されていないものは「計測」もできていなかった
    • 計測したメトリクスをもとにした効果的な改善サイクルを回すことができておらず、どのような優先順位で「改善」を実施していけばよいかわからない状態だった
  • データ基盤の処理の連続性
    • 後段のGoogle Cloud側から前段のAWS側の処理の状態を把握できていなかった
    • それゆえ、AWS側からGoogle Cloud側へ処理が移る部分で、処理が終わっているであろう時間にスケジュール実行する実装になっていた
    • 最悪の場合、AWS側の処理が未完了の状態でGoogle Cloud側の処理が始まるとエラーとなりデータ連携が止まってしまう状態であった

目的

KANTプロジェクトの目的は以下の2点です。

  • Classiのデータ基盤のジョブの実行状態の収集・集約・把握・監視および実行制御を責務としたデータ基盤監視システム(KANT)の構築(Classiではデータ関連のシステム基盤には哲学者の名前をつける慣習があります。)
  • KANTで集約した情報をもとに、SLA/SLOを定め、KANT外のBIツールで可視化し、改善アクションを行う

KANTの構築

アーキテクチャ

実装内容

前提として、既存のデータ基盤では、Amazon RDSからAmazon S3にデータを出力する処理をAWS Glueで、S3→Google Cloud Storage→BigQuery部分の処理をCloud Composerで行っています。KANTが収集する実行状態はこの2箇所となります。データ基盤の詳しい実装にご興味があればこちらの記事で紹介しています。(Classiのデータ分析基盤であるソクラテスの紹介

ジョブの実行状態の収集・集約

  • Glueの実行状態

    AWS Lambdaで実装し、1時間おきにGlueのジョブの実行状態を取得し、Cloud Loggingに送っています。Glueのジョブの中でログを送らず、Lambdaでの外形監視をしている理由はジョブだけでなくGlue全体の監視を行うためです。例えば、何らかの理由でトリガーが発火せずジョブが実行されなかった場合、ログが送られず実行状態を把握できない、といったことを防ぐためです。収集したログはCloud LoggingのLogs Routerを使い、BigQueryに集約しています。 メトリクスは各ジョブの開始時刻と終了時刻、実行ステータスを収集しています。なお、ある処理が何月何日の実行分として動いているのかという日付も取得したかったのですが、ジョブとしてはこの情報は保持しておらず、取得は断念しました。

  • Cloud Composerの実行状態

    Cloud Composerの処理のログをCloud Loggingに出力しています。収集したログはCloud LoggingのLogs Routerを使い、BigQueryに集約しています。メトリクスは各タスクの実行日(execution_date)、開始時刻(start_date)と終了時刻(end_date)、実行ステータスを取得しています。

ジョブの実行状態の監視

監視は、「Glueのエラー監視」「データ基盤の連携遅延監視」の2パターンを実装しています。

  • Glueのエラー監視

    Cloud ComposerのDAGで実装しています。DAGは、ジョブの実行状態を集約したBigQueryを定期的に確認し、エラーがあればSlackへ発報するようにしています。以前は、Glueからのエラー発報ができておらず、後段の処理のCloud Composerが動かないことでようやく気づくという状態でしたが、KANT実装後はGlueのエラーにも気づくことができる状態となっています。

  • データ基盤全体の遅延監視

    上記と同様、Cloud ComposerのDAGで実装し、ジョブの実行状態を集約したBigQueryを定期的に確認します。データ基盤がプロダクトレベルになるにあたり顧客に遅延なくデータを届けられるよう、各処理ごとに「〇〇時までに処理が完了している」というSLOを定め、そのSLOを満たしていない場合に遅延アラートをSlackに発報するようにしています。

ジョブの実行制御

前述のように、以前は前段のGlueの実行状態にかかわらず、後段のCloud Composerをスケジュール実行していました。KANT実装後は、Cloud Composerの1つ目のDAGの前に新たなDAGを追加しています。新たなDAGは、ジョブの実行状態を集約したBigQueryを見に行き、Glueの処理が完了しているかを確認します。Glueの処理が完了していれば、既存のデータ基盤の1つ目のDAGをキックするように実装しています。

可視化とその効果

可視化は、集約したBigQueryをデータソースとしTableauで行いました。以下で可視化の一部をご紹介します。

Glueの各ジョブ、Cloud Composerの各タスクの実行時間の可視化

GlueとCloud Composerの処理ごとの実行時間を可視化しました。可視化以前は数百ある処理ごとに開始時刻と終了時刻をSlack通知で垂れ流しているのみだったところ、現在では以下の情報をまとめて表示でき、一目で全体像が把握できるようになりました。

  • 各処理の開始時刻と終了時刻
  • 各処理に要した時間
  • 各処理と他の処理との処理時間の比較
  • 各処理の終了時刻が終了想定時刻より遅いのか否か

処理の状態を把握するだけではなく、実行に長時間要している処理や終了時刻が想定時刻を過ぎている処理をこのビューから把握し、該当する処理をチームで優先的に改善していきます。例えば、下図の終了時刻が想定時刻(AM6:00)を過ぎている処理に対し、分散処理の実装や開始時刻の調整を行い、実行に要する時間と終了時刻の改善をしました。

Before
After

SLO/SLAの達成度の可視化

Classiのデータ基盤では、「◯◯時までに処理が完了している」というSLOと「障害の際にその日中に復旧させる」というSLAを定めています。上部にSLO/SLAを達成した日数の割合(%)を表示し、その下に日付ごとのSLO/SLAの達成状況を表示しています。HEGELの部分はGlueの処理を表し、SOCRATESの部分はCloud Composerの処理を表しています。 この可視化により、「現在のデータ基盤が安定しているかどうか」の共通認識をチームで持つことができるようになりました。

また、下の2つの図は2021/08時点(※)と、2022/06時点のSLA/SLOの達成状況です。前述の「Glueの各ジョブ、Cloud Composerの各タスクの実行時間」のビューや今回紹介しきれなかったその他のビューをもとに改善点の優先度をつけてデータ基盤の改善に取り組んでいった結果、データ基盤全体の連携速度が向上し、SLOが飛躍的に改善したことがわかるかと思います。

チームでは、2週間に一度のレトロスペクティブの際にSLO/SLAの達成率を確認しています。毎回改善されていくSLO/SLAに達成感を感じ、チームの士気が高まることもKANTプロジェクトの効果だと思っています。

Before
After

※KAMOGAWAはHEGELの前身です。千葉の鴨川が由来です(哲学者じゃないんかいw)。KANTで明らかになったGlue処理の問題点をリファクタした際にHEGELに生まれ変わりました。

データ監視基盤の進化とさらなる品質の向上に向けて

データ監視基盤は実行状態の収集・集約・把握・監視および実行制御が責務である、と冒頭で紹介しました。しかし、障害時の再実行や自動復旧など実行制御についてはまだ不十分な点があります。また、今回はデータ基盤の品質向上に取り組みましたが、この他にも、データ基盤で処理する中でデータの欠損や重複が発生していないか、データソースと同じデータが連携されているか、など連携されるデータそのものの品質向上にも今後取り組んでいかなければなりません。

上記のように「データ監視基盤」「品質」というキーワードだけでもまだまだやることはあります。 Classiのデータ基盤を開発しているチームでは、各人が主導するプロジェクトを持ちながらも時にはプロジェクトのスピードアップや各人のスキルアップを目的に、タスクを共有し協力して開発を行なっています。そういった環境でスキルアップをしてみたい方は以下よりご応募ください!

hrmos.co

hrmos.co

チームトポロジーを参考に新組織の編成を考えた話

みなさん、こんにちは。開発本部で本部長をしている徹郎(id:tetsuro-ito)です。 Classiでは今年度より組織のあり方を少し見直し、チームトポロジーの考え方も導入してみたので、今回はその過程の話を紹介します。

Classiのこれまでの組織

Classiでは、2020年に起こしてしまったセキュリティインシデントおよび高負荷障害の対策を全社でとるべく、組織のあり方を変えていました。

7月からは、動作しているすべてのコードに対して、チームの責任範囲を明確にしました。また、技術的な課題をそれぞれのチームの責任において改善するような動き方にも変えました。やるべきことが明確(「再建プロジェクト」と「セキュリティ強化」が最優先)で、かつ、チームが主体となって意思決定する形にしたことで、現在は各チームが担当する機能やリポジトリをしっかりとメンテナンスしていく、そんな体制になってきたと思います。

Classiで発生した2つの問題を繰り返さないために我々が取り組んでいることより抜粋

過去の組織図

このようにClassiのプロダクトに対して、それぞれの機能を担うフィーチャーチームが組成され、それぞれのチームが持つコードやリポジトリの責務も明確にし、プロダクトの再建とセキュリティの強化に取り組んできました。

当時の組織変更はとてもうまくいき、それまでなかなか改善ができていなかったプロダクトの内部品質を向上させたり、古くなってしまったライブラリや言語のアップデートやEOL対応も主体的に進めました。 その甲斐もあり、1年後には当時大障害を起こしてしまうようなアクセスが来ても、プロダクトは止まることもなく、繁忙期のアクセスも捌けるような状態に改善しました。

徐々に顕在化した課題

しかし、個々の責務を明確にし、それぞれの主体性をもって問題解決にあたれるようになった結果、それぞれのチーム内の責務については順調に解決できる一方、チームをまたぐ問題や全社に共通するような大きな課題が徐々に顕在化するようになりました。

組織的な課題を解決した結果、ボトルネックが移動したと認識しており、着実に進んでいるという認識ではあったものの、一つ一つが大きな課題や問題のため、経営陣やマネジメントレイヤーはこの問題をどのように解決していくべきかを検討し始めました。

そんな折に、「チームトポロジー 」が出版されました。「はじめに」で引用されているコンウェイの法則の文言に魅了され、この考え方をベースにしながら新組織のあり方を検討していきました。

システムを設計する組織は、その構造をそっくりまねた構造の設計を生み出してしまう、というのが基本的な主張だ。この事実がシステム設計の管理において重要な意味を持つことがわかった。主要な発見は設計を行う組織を構造化するための基準である。コミュニケーションの必要性に応じた設計活動を行うべきなのだ。

個々のチームで扱いにくい大きな問題に対処できるような組織体制を構築し、これまでうまくいっていた部分を引き継ぎながら新たな組織の形を見出すことで、課題の解決を目指しました。

新たな組織体制

新たな組織体制

新たな組織として、4つのストリームアラインドな領域を定義しました。

  • 学習I(ラーニング)
  • 学習II(コーチング)
  • コミュニケーション
  • サービスオペレーション

学習I(ラーニング)

学習I(ラーニング)領域は生徒のラーニングサイクルを確立し、目標に向けて学ぶことをミッションとした領域です。Classiの機能では先生が生徒に向けてテストや宿題を配信するWebテスト、オンラインで自主的に問題に取り組むWebドリル、オンライン授業を視聴することのできる学習動画などの学習機能があり、それらを担当しています。 また、5月末にリリースしたAIを搭載した個別学習機能もこちらの領域が担っています。 https://corp.classi.jp/news/2710/

学習II(コーチング)

学習II(コーチング)領域は学習I(ラーニング)領域の表裏一体となり、先生が生徒と向き合いコーチングを行うサイクルを確立するためのミッションを持った領域です。 生徒が自分の学習時間などを記録する学習記録や、生徒一人一人の情報を確認することができる生徒カルテなどの機能があり、それらを担当しています。 こちらも今年度、学校内のClassiの利用状況が一元的に把握できるダッシュボード機能をリリースしています。

コミュニケーション

コミュニケーション領域はClassiのコミュニケーション機能全般を担っています。先生から生徒へ、先生から保護者へ連絡をすることができる校内グループや保護者から学校へ連絡ができる欠席連絡、他にもアンケートやコンテンツボックスといった機能も担当しています。 それぞれのユーザーや各機能をつなぐハブとして重要なミッションを持った領域です。

サービスオペレーション

サービスオペレーション領域はClassiを利用するにあたって根幹となる機能である設定登録やログイン/認証、連携サービスなどの機能を担当する領域です。ミッションとしてはClassiの利用開始から終了までの顧客体験に一本の筋を通すことを担っています。また、Classiではいくつかのネイティブアプリを運用していますが、それらの責務もこの領域が担っています。*1

これらのストリームアラインドチームを支えるためにプラットフォームチームとして、システムプラットフォーム領域を設定し、イネーブリングチームとしてはシステムイネーブリングチームを置いています。

Classiでは、この組織体制でそれぞれのラーニングサイクルとコーチングサイクルを確立させ、それらを有機的に紐づけていくことで、学校活動をつなげていくことをミッションとして活動しています。

ミッションのイメージ図

組織は作って終わりではありません。まずはこのように設計し、走り出しましたが、その時の最善が今後も最善であり続ける保証はありません。チームトポロジーでは冒頭で下記のようにチームを説明しています。

組織は単に自律的なチームを求めるだけでは不十分で、顧客に素早く価値を届けるためにチームのことを考え、チームを進化させなければいけないのだ

私たちも、この教えに従い、顧客に素早く価値を届けられる組織として継続的に進化していけるようにしていきたいと思っています。

*1:サービスオペレーション領域は責務分割が完全ではなく、現在の組織を運用しながらもう一歩踏み込んだ責務分割を検討していく予定です。

Content Security Policy のレポートを収集するためにやったこと

はじめに

こんにちは、開発本部所属エンジニアの id:kiryuanzu です。 現在、Classi ではサービスのセキュリティリスクをできる限りなくすために Content Security Policy を導入して脆弱性を検知する仕組みの導入を進めています。

本記事ではこの仕組みを導入する上でどのような手順が必要であり、どのような箇所で苦戦するポイントがあったかについて紹介していきます。

筆者は今まで CSP対応に携わったことがなかったのですが、導入段階の時点で想定していたよりも様々な知識が必要なことがわかり、記事にしたいと思いました。 もし数ヶ月前の自分と同じように初めてCSP対応に関わる人の一助となれば幸いです。

Content Security Policy (通称: CSP) って何?

Content Security Policy とは、HTTPヘッダの種類の1つであり、クロスサイトスクリプティング(XSS) やデータインジェクション攻撃といった、特定の種類の攻撃を検知し影響を軽減するために追加できるセキュリティレイヤーです。 MDN のドキュメント内に詳細な記事が存在するため、より具体的に知りたい方はそちらの記事も参照ください。

developer.mozilla.org

CSP導入の方針

今回 Classi で CSP対応を導入することになった経緯としては、EOL を迎えたフロントエンドライブラリが含まれており、なおかつクローズ時期が迫ったサービスに対して、バージョンアップの対策を取らない代わりにCSPを導入して脆弱性が発生した時にすぐ検知できるようにすることになったためです。クローズ時期が近いサービスに対してバージョンアップ対応に時間を割くよりも、CSP対応を行ってEOLオーバーを許容する環境を用意することで運用コストを低くかつEOLによるリスクを抑えられると判断し、導入を決定しました。

まずは上記のような緊急性の高いサービスを優先してCSP対応を行い、バージョンアップを定期的に行いクローズ時期の予定がないサービスに対しても順次CSPを導入していくことにしました。

今回 CSP 対応をする中での基本方針は以下の通りでした。

  • Content Security Policy Report-Only ヘッダを使うことで違反内容の検知をレポートだけにとどめる
  • できる限り厳格な規格に沿ってポリシーを設定する
  • セキュリティ部署との密な連携

方針に対して具体的にどのような取り組みを行ったかひとつずつ紹介していきます。

Content Security Policy Report-Only ヘッダを使うことで違反内容の検知をレポートだけにとどめる

CSP には2種類のヘッダがあり、 1つ目は Content-Security-Policy ヘッダです。このヘッダを設定するとポリシーの違反内容に引っかかった時に違反内容をコンソール上に表示し、サイトのブロッキングまで行います。

2つ目は Content Security Policy Report-Only ヘッダがあります。このヘッダはサイトのブロッキングはせず違反内容のレポートのみになります。

もし Content Security Policy ヘッダを本番環境で設定した場合、想定しない場面でサイトがブロックされユーザーに不便が生じるリスクがあります。このリスクを回避するため、まずは staging・production 環境においてContent Security Policy ヘッダを設定しレポート系のサービスに収集させることにしました。

Content-Security-Policy-Report-Only の report-uri ポリシーを使って Sentry などのバグ収集サービスと連携させることで、CSPが検知した違反内容を Sentry に飛ばしイシューとして管理・Sentry に積まれた違反レポート内容を slack 連携してチャンネルに流す運用を今回行いました。

docs.sentry.io

できる限り厳格な規格に沿ってポリシーを設定する

CSP では以下のようにポリシーを指定して特定の記述やオリジンを許可したりブロックしたりできます。

Content-Security-Policy: default-src ‘self’; script-src example.com ‘sha256-xxx’’; style-src: example2.com ‘sha256-yyy’

特定のインラインコードやインラインスタイルを許可したい時はハッシュ値や nonce (一時的に利用する乱数のこと)を設定して対応するように進めました。もしひとつひとつ許可する方法に対してコストを高く感じる場合は、unsafe-inlineunsafe-styleを各ポリシーに設定することで許可できます。しかし、意図していないコードの実行を許可してしまうことに繋がるためこれらの設定は極力使わないようにしました。

CSP の規格として現在推奨されている規格として CSP Level 3 といった内容が存在しています。

w3c.github.io

この Level 3 は今まで示されていた Level 2 の対策よりもよりセキュアな仕様とされています。CSP Level2 から Level3 における XSS対策は pixiv さんの記事で詳しく紹介されているので、気になる方はぜひ読んでみてください。(今回のCSP対応を進める中でも大変参考になりました)

inside.pixiv.blog

セキュリティ部署との密な情報連携

対応を進める中で、現状のサービス設計だと厳格なポリシー設定を守れないケースが出てきたり、 CSP 違反で検知されたコードの調査が難しい場面がありました。 そういった問題に対しては、セキュリティ部署のエンジニアと一緒に現状の設定でセキュリティリスクがないかどうかや違反されたレポートが無害であるかどうか相談をする場を設けて現状の運用に対しての合意を取るように進めました。

CSP対応を進めるうえで必要だった実作業の紹介

初めてCSP対応を進める上でキャッチアップが必要かつ時間をかけて考える必要のあった作業を紹介していきます。

サービスの技術セットに合わせてヘッダの設定場所を調査する

実装に手をつける前に、まずはCSPのヘッダを設定できる場所を調査する作業から始めました。

Report-Only ではない方のCSPは HTML の metaタグを設定するだけでCSPの設定を適用できますが、Report-Only では対応されていません。サーバー側でレスポンスヘッダを設定する方法で対応を進める必要があります。

この方法で対応する場合、Rails 等のフレームワーク内の標準機能を利用する方法や nginx などのリバースプロキシ上で設定する方法など、サービスの技術セットに応じて設定する箇所を見つける必要がありました。

1つ例を紹介すると、Rails では、CSP を設定するための DSL が提供されています。 railsguides.jp

今回 CSP対応を行ったサービスの1つは、開発上の都合で S3に用意したフロント側のファイルを public 以下に配置する設計となっており、Railsは静的ファイルを配信するサーバーとして使われている構成だったため、上記の DSL は利用しませんでした。(なぜこうなっているかは CSP の文脈から逸れてしまうので割愛します)

application.rb 内で config.public_file_server.headers の設定を利用することで CSP のレスポンスヘッダを設定しました。 この設定は Cache-Control 等の他のヘッダを指定したい時にも使われています。

railsguides.jp

# application.rb

# CSP記述箇所以外は省略
    config.public_file_server.headers = {
      'Content-Security-Policy-Report-Only' => "default-src 'self';

このようにしてサービスの設計に合わせて設置場所を定めるための調査をする必要がありました。

その中で、設置場所によっては容易にできる実装だったものが自力で用意しなければならない局面があり、少し時間をかけてしまいました。 次のパートで詳しく紹介していきます。

特定のスクリプトに値を動的に変更する nonce の設置対応

Level3 の仕様の中では以下のコードのように、 nonce を使って javaScript の実行を制限する方法が推奨されています。

# index.html

<script nonce="hogehoge111">
  alert(“piyo”)
</script>
# nginx.conf

add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'nonce-hogehoge111'

nonce とは「一回限り有効なランダムなデータ。リプレイ攻撃などを防ぐために用いる」といった意味の単語です。CSP ではこの nonce を固定値のまま設定して制御する方法も可能ですが、その方法だとポリシーの設定によっては nonce の値を攻撃者から悪用されるリスクもあるため、リクエストごとに変更する設定にしました。

特定のスクリプトに nonce を設置する際、前者で紹介した DSL では用意された設定を用いることで容易に nonce の自動生成を行うことができます。(具体的な設定方法は https://railsguides.jp/security.html#content-security-policy%EF%BC%88csp%EF%BC%89を参照してください)

しかし、Rails の public_server を使った設定や nginx で設定する場合はこちらで nonce を動的に変更にするための実装を考える必要がありました。

nginx で nonce の値を動的に変更できる対応を行うために、 ngx_http_sub_module というライブラリに用意された sub_filter メソッドを利用してフロントエンドのコードに 仮の nonce の値(placeholder)を埋め込んで nginx の設定の中で placeholder の値からリクエストID に置き換えることでリクエストごとに nonce を用意しました。

Module ngx_http_sub_module

Easy nonce-based Content-Security-Policy with Nginx | Infrastructure and Application Security

# index.html

# placeholder の値を CSP_NONCE にしている
<script nonce="CSP_NONCE">
  alert(“piyo”)
</script>
# nginx.conf

load_module /usr/lib64/nginx/modules/ngx_http_opentracing_module.so;
opentracing on;
# リクエストIDを変数で使えるように設定
opentracing_tag nginx_request_id $request_id;
opentracing_trace_locations off;

load_module  /usr/lib64/nginx/modules/ngx_http_subs_filter_module.so;
sub_filter_once off;
# フロント側でplaceholder の値として設定した CSP_NONCE 文字列をリクエストIDに置き換えて nonce の値として使うように
sub_filter CSP_NONCE $request_id;
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'nonce-$request_id'

placeholder の値(ここではCSP_NONCEの文字列として記述)自体も、固定値のままにしておくとバレてしまった際に攻撃者から利用されるリスクがあるので、できるだけ動的に変更にした方が良いというアドバイスを社内でいただきました。

そのため、CircleCI の中で placeholder にあたる値を乱数に置換するコマンドを用意し、nginx.conf 内にある placeholder も置換するようにしてデプロイごとに変更する仕組みを用意しました。

# .circleci/config.yml

  set_nonce:
    steps:
      - run:
          name: Set NONCE
          shell: /bin/bash
          command: |
            nonce=$(cat /dev/urandom | base64 | fold -w 16 | head -n 1)
            sed -i "s@CSP_NONCE@$nonce@g" src/index.html
            echo "export NONCE=${nonce}" >> $BASH_ENV
      - run: echo ${NONCE}
# *この設定は必要最低限抽出したもので、実際には他の引数やパラメータも用意してビルドしています
jobs:
   image-build-and-push:
     steps:
       - checkout
       - set_nonce
       - aws-ecr/build-and-push-image:
         repo: xxx
         dockerfile: docker/frontend/Dockerfile
         extra-build-args: "--ssh default --build-arg NONCE=$NONCE"

ビルド時に CircleCI 側で用意した環境変数を Dockerfile の中で 置換する処理を入れ、デプロイ時に placeholder を書き換える処理にしました。

# nginx.conf

ARG NONCE
RUN sed -i "s|CSP_NONCE|${NONCE}|g" /etc/nginx/nginx.conf

このようにして、nginx 内の設定でも nonce の値を動的に変更するように設定し、よりセキュアなやり方で nonce方式でCSPを運用できるようにしました。

ただし、上述で紹介した Rails の config.public_file_server.headers の設定では自力で実装することが難しかったため、先ほどのコードで示したような nonce の値を CircleCI の中で置換する仕組みを用いて nonce 自体をデプロイごとで動的に変更できる設定を行うことにしました。

この対応は厳格なやり方から外れてしまいますが、他のポリシーで必要最低限の脆弱性から守るようにすることをセキュリティ部署のメンバーと合意し運用するようにしました。

CSP のポリシーの設定・運用フェーズへの体制を整える

上記のような nonce 対応も行う必要がありますが、基本的には実際に CSP のポリシーをサービスに適用するうえで現状の違反内容をチェックし、1つずつ解消していく作業をやっていきました。

ポリシーをdefault-src: selfのみ適用することで、現時点で存在する違反内容がコンソール上で表示されるため、この違反内容を1つずつ精査し、特定のハッシュ値や nonce の適用・不要なインラインスクリプトの削除を進めました。

この作業の中を進める上で、特定のスクリプトに対応するハッシュ値を記録するドキュメントを残して対応するスクリプトが不要になった時は削除できるように管理しています。 特に大変だったこととして、動作する環境やブラウザによって発見されるレポートが変わることが多々あり、原因を調査をしていくのにかなり時間がかかりました。

今は対処できる違反レポートを一通り対処し終わった段階で、Sentry のレポートを slack に流す連携を入れ、社内ドキュメントに運用ルールを記載し、トリアージやエスカレーションの方法を取り決めて運用体制を整えました。

まとめ

上記のような導入作業を終わらせてCSPのレポートを運用する状態に持っていくことができました。ただ、導入してからこそが本番でこのレポートたちの情報を元に傾向をまとめたり必要な対策を実施していく必要があると考えています。

jxck さんが執筆された CSP Report 収集と実レポートの考察 | blog.jxck.io のような活動を社内のレポートを元に行っていくのが次の目標です。

今後CSP対応に関わる方に参考になる話があれば幸いです。読んでいただきありがとうございました。

© 2020 Classi Corp.