Classi開発者ブログ

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

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

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

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

リードタイムの定義

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コミュニティの面白さを知ってもらいたいな……と野望を秘めつつ、この文を締めたいと思います。
読んでいただきありがとうございました!

Classiの個別最適化エンジン CALE v2.0リリースまでの進化

はじめに

ClassiのPythonエンジニアで AL(アダプティブラーニング)チームのエンジニアリードの工藤 (id:irisuinwl) です。こんにちは。
最近のマイブームは寿司を握ることです。

さて、今回の記事ではアダプティブラーニングエンジン CALE の進化について書きたいと思います。

CALEとは、ざっくりいえば生徒の解答履歴を分析して能力を推定し、成績向上に役立つ問題を推薦するレコメンドエンジンです。

詳しくは以前の記事を参照ください:

tech.classi.jp

tech.classi.jp

Classiでは今年、新機能である学習トレーニング機能(以下、学トレ)をリリースしました。
学トレの一つに「AIが一人ひとりに合わせたおすすめ問題を提示する」機能があり、そちらでCALEが使われています。

corp.classi.jp

元々学トレリリース前ではWebテスト機能にて個別最適学習を実現するためのレコメンドを行っており、そのロジック実現のためCALE v1.0は利用されていました。

今回、学トレでのおすすめ問題機能を実現するにあたって、CALEを進化させるため、CALE v2.0 を開発しました。

この記事では、CALE v1.0からv2.0へどのように進化したかを紹介します。

  • はじめに
  • 概要
  • 従来のアーキテクチャ
  • アプリケーション基盤の変更
    • 移行を決定した背景
    • 移行作業
  • ユースケース設計とAPI設計の変更
    • 学習トレーニング機能のユースケース設計
    • API設計の見直し
  • データモデリングとストレージの変更
    • データモデリング
    • ストレージ構成の見直し
  • サービス構成の変更
  • まとめ

概要

CALE v1.0 ではリリース後の運用を通して、以下の課題が見つかりました。

  • Kubernetes の運用負担が大きい
  • 複雑なユースケースから生じる複雑なAPIエンドポイントとデータモデル
  • 当初の期待に対して過剰で複雑なストレージ構成とサービス構成

それに対してCALE v2.0では複雑にならないように以下のようにアーキテクチャ変更・開発の工夫をしました。

  • アプリケーション基盤の変更: Google Kubernetes Engine (GKE) から Amazon Elastic Container Service (ECS) へと移行
  • ユースケースの抽出とAPIエンドポイントの簡素化: ユースケースを課題配信・問題解答・レコメンドの3つを抽出、v1では8つあった API エンドポイントをv2では3つに絞り込みました。
  • ストレージ構成の変更: MySQL + Firestore + Google Cloud Storage (GCS) から MySQLのみの構成へと変更
  • サービス構成の統合: APIサービスとレコメンドロジックサービスの分離構成を統合し、モジュラーモノリスへと変更

結果としてシンプルなアーキテクチャを実現することができ、効果として以下のリードタイム短縮とレイテンシー改善をすることが出来ました

  • リリースまでのリードタイム中央値: 289時間 -> 129時間
  • 課題配信API p95 平均レイテンシー: 
    • v1:258ms -> v2: 26.8ms
  • 問題解答API p95 平均レイテンシー: 
    • v1: 413.7ms -> v2: 103.8ms
  • レコメンドAPI p95 平均レイテンシー: 
    • v1: 497.5ms -> v2: 28.6ms
続きを読む

Cloud Composerローカル開発ツールで運用業務を圧倒的に楽にする

こんにちは、データプラットフォームチームでデータエンジニアをしています、鳥山(@to_lz1)です。

本記事は datatech-jp Advent Calendar 2023 11日目の記事です。

データ基盤を運用する皆さま、ジョブの実行管理基盤には何を用いているでしょうか?

様々なOSSやサービスが群雄割拠するこの領域ですが、Google Cloud Platform(以下、GCP) のCloud Composerは選択肢の一つとして人気が根強いのではないかと思います。

一方、同サービスには時折「運用がつらい」とか「費用が高い(故に、開発者が気軽に触れる開発環境を用意できない)」といった言葉も聞かれるように思います*1

自分も入社以来触ってきて、そういった側面があることは完全には否定できないと考えています。しかしそれでも、ツールを用いてそうした負担を大幅に軽減することは可能です。今回はGCPが公式に提供するCloud Composerローカル開発CLIツールを実際の開発フローに導入したことと、それによって得られたメリットをご紹介します。

  • Cloud Composerローカル開発CLIツールについて
  • 導入前の課題
    • Cloud Composer環境の再現がつらい
    • Cloud Composerのアップグレードがつらい
  • 向き不向きに関する考察
    • PyPIパッケージのインストール・検証🙆
    • バージョンアップの検証🙆
    • 向かないと思われる用途
  • ローカル環境構築のスクリプト化
    • 1. Variablesのダミーの準備
    • 2. Poetry Export
    • 導入した結果
      • 残った依存とrenovateとの相性
      • そもそも依存をインストールすべきか?について
  • まとめ
続きを読む

Browserslist + GitHub Packageでサポート対象への対応を簡単にする

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

皆さんが開発しているプロダクトでは、サポート対象の OS やブラウザのバージョンは規定されていますか? Classi ではそういった推奨環境が定められています。

ビルド後に推奨環境で動作させるために、社内で GitHub Package として公開された Browserslist の設定ファイルを使ったところ体験が良かったので、今回はその紹介をしようと思います。

Browserslist とは

その名の通り、下記のようなブラウザのリストを記述することで、フロントエンドのツール間でサポートするブラウザのバージョンを共有できるツールです。

last 2 Chrome versions
last 1 Firefox version
last 2 Edge major versions
last 2 Safari major versions
last 2 iOS major versions
Firefox ESR

例えば .browserslistrc に iOS >= 16 と記述してから npx browserslistを使用すると、次の結果が得られます(執筆時点での iOS の最新版は 17.1)。

$ npx browserslist
ios_saf 17.1
ios_saf 17.0
ios_saf 16.6-16.7
ios_saf 16.5
ios_saf 16.4
ios_saf 16.3
ios_saf 16.2
ios_saf 16.1
ios_saf 16.0

ここに出力されたブラウザをサポートするように、他のライブラリに伝えることができます。

Angular CLIでは、内部でこの Browserslist が使われており、Autoprefixer と babel にサポートブラウザを伝える役割を担っています。

なぜ Browserslist + GitHub Package を導入したか

Browserslist + GitHub Package を導入したきっかけは、Angular を v15 から v16 にアップデートしたときに、 Classi で動作保証している端末で画面が一部表示されない障害が起きたことでした。

そのリポジトリでは自前の Browserslist の設定はしておらず、Angular のデフォルト設定をそのまま使用していました。

browserslist.defaults = [
    'last 2 Chrome versions',
    'last 1 Firefox version',
    'last 2 Edge major versions',
    'last 2 Safari major versions',
    'last 2 iOS major versions',
    'Firefox ESR',
];

https://github.com/angular/angular-cli/blob/66b18b654bb47a320e686c4a9c752da64c52830e/packages/angular_devkit/build_angular/src/utils/supported-browsers.ts#L12-L20

Browserslist は caniuse-lite を使ってクエリ用のブラウザ DB を参照しています。

Angular のアップデートに caniuse-lite の更新も含んでいたため、その時新しくリリースされていた iOS 17 が Browserslist に反映されてlast 2 iOS major versionsの指すものが変わってしまい、障害につながってしまいました。

他のリポジトリでも発生しそうだと思い他チームにも共有したら「それなら設定を一箇所にまとめて各リポジトリから使えるようにしたいよね」という話があがり、社内のメンバーが Browserslist 設定用の GitHub Package を作成してくれました。

Browserslist + GitHub Package誕生までのスピード感のある会話

使い方

GitHub Packages の公式ページを参考に Browserslist の config ファイルをインストール後 Browserslist の設定ファイルで extends 構文を使用すれば使うことができます。

"browserslist": [
  "extends browserslist-config-mycompany"
]

https://github.com/browserslist/browserslist#shareable-configs

GitHub Packages を使うために CI の設定ファイルも書き換える必要がありますが、GitHub Actions を使っている場合は下記のようにすると package をインストールできるようになります。

steps:
  - uses: actions/checkout@v4
  - name: "Install npm dependencies"
    uses: actions/setup-node@v3
    with:
      node-version-file: ".node-version"
      registry-url: "https://npm.pkg.github.com"
  - run: yarn install --frozen-lockfile
    env:
      NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

簡単!

最後に

Classi はリポジトリ数が多く設定が分散しやすいので、今回紹介した Browserslist + GitHub Package は今まで使ってこなかったのが不思議なくらい便利でした。

今までサポート対象のブラウザに対応してない記法を使って不具合を起こしてしまった方や、各リポジトリで設定が統一されていないことに課題感を覚えている方がいたらぜひ試してみてください!

ここまで読んでいただきありがとうございました。

© 2020 Classi Corp.