Classi開発者ブログ

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

SRE留学体験記(第3期生)

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

この度わたしはSRE留学の第3期生として、2023年5月-10月の半年間SREチームに留学をしました。
SRE留学とは?や、第1期生、2期生の体験記はこちらをご覧ください。
tech.classi.jp tech.classi.jp

SRE留学を志望した理由

留学以前は主に認証機能の運用開発をするプロダクトチームで仕事をしていました。弊社のプロダクトチームはただアプリケーションコードの実装をするだけでなく、担当機能のインフラ管理や監視等もすることが多々ありその中でAWSに触れ、サービスの様々なことを考えながらも全体を操作している感覚を漠然と楽しいなと思うようになりました。またCI・CDを考えたり物事の自動化や効率化が好きだったのでそのあたりに特化して仕事をするのはSREなのでは?と思うようになりSRE留学に興味を持ちました。

留学後にありたい姿

新しくAWS等のインフラ知識を積極的に獲得し今後の開発に生かしたい

プロダクトチームでは主にRuby on RailsとAngularでのバックエンドとフロントエンドを開発することが主だったのですが、何か機能を作ろうとした際にはもちろんその機能がどのくらいの規模で使われるのか、監視はどのように行うのかなどインフラに関することも考える必要がありました。バックエンドとフロントエンドに注力する機会が今まであったからこそ、この機会にインフラに注力して知識や経験を獲得することで、サービスを考える際にフロントエンド、バックエンド、インフラに対して一本の道を通して様々なことを考えられるようになれるのではと思い、その状態を目指したいと思いました。

チームを横断した考え方や動きができるようになりたい

プロダクトチームではどうしてもその機能、チームに閉じた動きをしてしまいがちなのですが、SREという帽子を得ることでチームの壁を感じにくくなるだろうと思いました。また過去の留学生が留学中や留学後にそのような振る舞いをしているように見え、自分もそのようになりたいと考えました。

留学中にできたこと

SREチームではカンバンを使ってタスクを管理しており、留学中は日々そのタスクをこなしていくことがメインになります。その中でわたしが実施したタスクを2つ紹介します。

ECSタスクの異常停止の検知とイベントログの保存

これはECSのタスクが起動に失敗しかつその検知が遅れサービスにアクセスできない障害が発生しまったこと、また暫定対応後に起動失敗の原因を調査しようとした際に該当ECSタスクのイベントログがすでに消えてしまっていたことからネクストアクションに積まれていたものでした。

弊社にはサービスのアラートを流すSlackチャンネルがあるため、起動失敗や異常停止を検知した際には以下のようにチャンネルに通知するようにしました。

Slackに投稿されるメッセージ

Amazon EventBridgeにて異常停止、起動失敗のイベントのみをフィルタし、Amazon SNSとAWS Chatbotを使いSlackに投稿しています。

構成図

またECSのイベントログもAmazon EventBridgeを使いCloudWatch Logsに保存するようにしました。

この仕組みを構築後、リアルタイムでECSタスクの停止を検知できるようになりMTTRを向上させることができました。また過去のログも遡れるようになり事後調査がしやすくなりました。実際にいろんな人にこの仕組みができて助かった、というような声をもらうことができとても嬉しかったです。

Amazon Auroraの監査ログをCloudWatch Logsを経由せずS3に保存する

弊社のAWSにかかっているコストを調査したところCloudWatch Logsの特にAmazon RDSの監査ログの保存にコストがかかっていることがわかりました。コストバランスを意識し改善する機会はSREチームに所属してからとても増えました。
また個人的にはRDSに触るのは研修ぶりというくらいに久しぶりで前提知識がほとんどなく、まずはRDSの勉強から始めました。
監査ログってどこで書き込まれているの?どこで設定されているの?Amazon Aurora、MariaDB、MySQLはどう違うの?などなど様々なことを調べました。SREチームのリーダーが「ただタスクを急いで消化するのではなく、ゆっくりでいいからタスクを進めながらたくさん寄り道をして欲しい」と言ってくれたのもあり、丁寧に勉強することができました。
最終的にはEventBridge SchedulerとLambdaを使用し、仕組みを構築することができました。この仕組みについては改めてブログを書こうと思います。
当初の自分のレベルより格段に上のタスクをこなすことができ、とても自信がつきました。

留学中に難しかったこと

大きな課題をタスクに分割すること

SREチームに与えられる課題は漠然としていて大きいものが多々あり、例えば、コストを削減して欲しい、ログをいい感じに管理して欲しい、などがありました。特にログの管理に関しては最初に見た時に、「なんとなくログの管理が統一化されていないのはわかるけどじゃあ統一するために何から始めたらいいのだろう、そもそもどの状態に統一すればいいのだろう」と課題を着手できるサイズのタスクに分割することができませんでした。そこで課題のタスク分割をSREのチームリーダーと一緒に進めさせてもらいました。
まずは現状把握をする、その後に現状の課題感を出してやることリストを作る、という流れを一緒に取り組ませてもらいました。
私は何かを進める時にまず明確にゴールを決めないと、と思っていたのですが、ここでは課題感を無くすために必要なタスクを洗い出すことで、課題感がなくなればそれはゴールだよねという考え方を教えてもらいとても納得感がありました。

プロダクト開発ではどちらかというと作りたい機能や目的を最初に明確にすることが多く、決まったゴールに辿り着くために必要な道筋を考えるということが多かったです。ですが今回のように「正しく管理されている状態」は一言でいうのは難しく、逆に言えば「現時点での問題点が解消されている状態」と言い換えられることがわかりました。課題に対する新しいアプローチ方法を体験しながら学ぶことができ、勉強になりました。

横断的な振る舞い、考え方の難しさ

留学後にありたい姿としても書いていたのですが、これを実現することはとても難しかったです。留学前は一つの閉じたチームにいるから他のチームの課題がなかなか見えない、SREという横断チームに所属すれば必然的に他のチームのことが見えてくるのでは、と考えていました。もちろんそんなことはなく、ただ横断チームに所属するだけではだめでそもそもの動き方や仕組みを考える必要があるというのを実感しました。またSREチームに対して投げられた質問等に対しても、自信の無さからか他の誰かの反応を待ってしまうことが多く主体的にガツガツ動けた、とは言えませんでした。

今後

累積矢面時間を稼ぎたい

なかなか横断的な振る舞いができなかった、という反省に対し「累積矢面時間」というお話をもらいました。
累積矢面時間とは野村総合研究所 ICT・メディア産業コンサルティング部 主任コンサルタントである鈴木良介氏の言葉であり、鈴木氏は、「戦略的・計画的に自身のキャリアをつくっていくためには、どれだけクライアントとの矢面に立ち、成功と失敗の経験を積んだか。それに尽きる」と説いています。 より詳細な詳細な解説は下記をご覧ください。

この話を聞いたうえで今回の留学を振り返ると、自分が矢面にたったと言える時間は少なかったと言えます。ただ常に隣でSREのチームリーダーが矢面に立って物事を考えたり、意思決定をしていて、その姿を半年間間近で見ることができました。

仕事力=引き出しの量×瞬発力

とあるように、この留学によって確実に「引き出しの量」は増やせたと思っています。あとは「瞬発力」を稼ぎたい、そのために「累積矢面時間」を稼ぎたいと思いました。

留学前は「リーダー」といったような役職に対して私には難しいといったマイナスイメージを抱いてしまうことが多かったです。ですがSRE留学を経て横断的な考え方や振る舞いを身につけるために「仕事力」を増やしたいと思い、それにより今は積極的にリーダーという役職にも自ら挑戦してみたいと思えるようになりました。この辺りは留学前と留学後で大きくマインドが変わったところで、そう思えるようになったことがとても嬉しいです。

今後は一度プロダクトチームに戻りますが、留学中に得たこれらの知識やマインドを持って引き続き業務に励みたいです。

Step Functions を使って、ECS のワンショットタスクを実行する

こんにちは、プロダクト開発部の藤田です。

今回は Amazon ECS(以下、ECS)AWS Step Functions(以下、Step Functions) を組み合わせた「ワンショットタスクの実行基盤」についてご紹介します。

「ワンショットタスク」とは、指定されたコマンドやスクリプトを1度だけ実行して、終了するタスクのことです。

このようなワンショットタスクは「プライベートネットワークの内側にあるリソースへのアクセスが必要な、非定型的な操作」を実行する際などに用いられます。

例えば、プライベートネットワークの内側にある DB に対して、以下のような作業をエンジニアが実施する際に、ssh を利用して踏み台サーバーに接続して、決められた手順書を実行する・・・といったような場面を想像していただければ、イメージしやすいと思います。

  • DB にテーブルを追加するための migration 実行
  • DB のマスターデータにレコードを追加
  • DB の特定のテーブルのレコードの一斉置換
  • DB の不要になったデータの一斉削除

実行したい操作の内容や時期も不定であり、作業に必要な踏み台サーバーは常にプロビジョニングされている必要はありません。 一方で、一般的には、商用環境に対するあらゆる操作の監査ログを収集・保持するというガバナンス要件があります。

これらの要件を満たすワンショットタスクの実行基盤を ECS と Step Functions で構築しました。

また、今回構築した「ワンショットタスクの実行基盤」は実行するスクリプトのランタイムに基本的に依存しない汎用的な仕組みであるため、参考になれば嬉しいです。 本記事の Appendix に Terraform コードの例を添えて、具体的な構築方法をご紹介します。

ワンショットタスクの仕組みが必要な背景

我々のチームでは Go 言語で実装された Web API の開発と運用を行っており、対象の API は ECS サービスとして実行されています。 この Web API は GraphQL を採用しており、GraphQL schema の管理方法については以下の記事でも紹介されているのでご参照ください。

また、データストレージとして Amazon Aurora を採用しています。

日々、機能の拡充や改善を進めていく過程で、DB のテーブル定義の追加・変更が求められたり、別のテーブルへのデータ移行や不要データの削除などが必要になったりすることもあります。

これまではこのようなデータのメンテ作業が発生した際は

  • エンジニアが作業手順書を作成する
  • エンジニアが作業手順書をレビュー・承認する
  • (二人以上のエンジニアがダブルチェック用に立ち会いながら)エンジニアが ssh を利用して踏み台サーバーに接続して、手順書の内容を実行する

といった作業を行っておりました。

しかしながら、上記のような運用では、本番の踏み台サーバーに ssh で接続するためには事前に管理部門からの承認が必要でそれらの手続き完了までに一定のリードタイムが必要となります。 仮に管理部門から承認されたとしても、ssh を利用して踏み台サーバーに接続した後は作業者が持っている権限の限りではどのような操作も行えてしまうので、作業ごとに最小限の操作を許可する設定を入れるといった複雑な運用をしない限りは、本当の意味でセキュリティ上安全とは言えません。

また、事前に作成した手順書を指定された順番通りに実行する際も、手順書のコピペミスなどのヒューマンエラーが発生する可能性は否めません(そのためのダブルチェックではありますが)。

上記のような理由からかもしれませんが、実際、自分自身もデータのメンテ作業が必要な機能改修に着手する際の心理的ハードルは高く感じていました。 この状況に課題を感じ、データのメンテ作業を安全に単純化できる仕組みの構築を進めることとなりました。

ワンショットタスクの仕組み構築の方針

go run コマンドで ECS タスクとして実行できる形にする

前述した通り、我々のチームでは Go 言語で実装された Web API を ECS 上でホストしており、ECS 上で稼働している API 用の ECS タスクはデータの読み書きを行っているため、データのメンテ作業の対象となる DB と接続している状況です。

つまり、API 用の ECS タスクと同様の条件下で、実行するコマンドだけをデータのメンテ作業用の内容に書き換えて実行することができます。 Go 言語では go run <スクリプトのファイル> で、実行したいプログラムをビルド&実行できるため、データのメンテ作業の種類ごとにスクリプトを実装して、ECS タスク上で go run を実行させることは容易でした。

また、API を実装しているリポジトリでは CI/CD を構築しており、テスト対象が API からデータのメンテ作業用のスクリプトに変わったとしても、想定する振る舞いを検証することに対してハードルはありませんでした。 スクリプトの実行前と実行後のデータの変化さえも単体テストで検証できる上、スクリプトの実装やテストケースの網羅性をコードレビューの中でレビューできる点が、開発者にとって非常に良い体験となっています。

更に、チームでワンショットタスクで実行するスクリプトをコードレビューするため「事前にチームでレビューしたコードのみが実行される」という点が保証されます。 加えて、ECS タスクで実行する場合はタスクにアタッチする IAM Role で権限制御も容易であるため、セキュリティ面も改善されていると言えます。

ワンショットタスクを簡単に実行できるようにする

ECS の RunTask API では、実行する ECS クラスターや ECS タスク定義の指定も可能ですし、containerOverrides で実行する command も override できます。

そのため、この時点でデータのメンテ作業の作業者が ECS の RunTask の API を実行する(もしくは aws cli で run-task を実行する)ことで、実装・レビュー済みのスクリプトを ECS タスクとして実行可能になっています。

ただし、RunTask 呼び出しの際に指定すべきパラメーターが数多くあるため、そこにもヒューマンエラーの余地が多くあります。 そこで、ECS の RunTask の呼び出しを Step Functions を介して行うようにします。

Step Functions ではこちらで記載がある通り、ECS の RunTask の呼び出しを標準のステップとしてサポートしており、与えられるパラメーターも ECS の RunTask API に準じています。

構築する Step Functions の全体像は下図の通りです。

Step Functions の全体像

作業者が Step Functions を起動して、RunECSTask というステップを実行して、終わるといったシンプルなものになっています。

また、作業者が Step Functions を起動する際に渡す引数のイメージは下図の通りです。

Step Functions の実行画面

“commands”: [“cmd/sample/main.go”] といった引数のみで、go run で実行したいスクリプトのパスのみを指定するといったこれまたシンプルなものになっています。

実際は RunTask を実行するためには、以下のパラメーターを指定する必要があります。

  • 実行する ECS クラスター
  • 使用する ECS タスク定義
  • ECS タスクを実行する際の Subnets や SecurityGroup といった NetworkConfiguration

ただし、上記のパラメーターは我々のチームの運用においては毎回変更・指定したいものではないため、作業者が都度意識するべき設定ではありません。 そのため、これらの設定は Step Functions の定義の中にハードコーディングして、Step Functions の実行者が意識するのは「実行したいスクリプトのパスのみ」という設計が開発者の体験として負担が少ないという結論になりました。

Step Functions でワンショットタスクを実行する際のメリット

  • データのメンテ作業用のスクリプトを go run で実行できる形で実装する
  • Step Functions で ECS のタスクの実行を簡単にできるようにする

上記の2つの方針で構築を進めた結果、我々のチームでは Go 言語でスクリプトを実装し、実装したスクリプトのパスを引数に Step Functions を実行するだけでデータのメンテ作業を簡単に、かつ、安全に進めることができるようになりました。

他にも、この仕組みを使った運用にはメリットがあります。

データのメンテ作業の履歴の保存

Step Functions は標準で下図のように実行の履歴が残ります。

Step Functions の実行記録

この履歴はそのまま、データのメンテナンス作業の作業履歴として扱うことができます。 履歴一覧の時点で、開始と終了の日時と成否のステータスが分かります。

更に、各実行の詳細も下図のように確認が可能で、Resource から実際に実行された ECS タスクへのリンクに飛ぶことができます。 (※下図では説明の都合上、一部のステップを除外して表示しています)

Step Functions の実行詳細例

ECS タスクの設定で Amazon CloudWatch Logs(以下、CloudWatch Logs) 等に実行スクリプトのログを出力するようにしておけば、後から当時の実行ログを参照することも可能です。

また、Step Functions の各ステップの実行ログを CloudWatch Logs に出力することも、AWS X-Ray にトレースを送信することもできます。

つまり、データのメンテナンス作業に関わる記録は、必要な設定さえ行えば AWS のサービスを利用して残すことができるのです。

他のチームへの知見・仕組みの共有しやすさ

我々のチームに限らず、Classi 社内では API などは ECS を使ってホスティングすることが多いです。 アプリケーション開発の技術スタックに関しては Ruby on Rails が多いのですが、Rails の場合でも事前に実装した rake タスクを ECS タスク上で実行可能ですし、他の言語に関しても同様の仕組みがあることが多いでしょう。 そのため、Step Functions の構築方法を模倣できれば、社内に関して言えば、基本的に実装のランタイムに拠らず今回のワンショットタスクの仕組みは他のチームでも採用しやすいです。

幸い、今回の Step Functions は Terraformを使ってコード化して管理可能なので、既に Classi 社内でも他のチームにも知見を共有して、使ってもらっています。

この記事の最後に appendix として Terraform のサンプルコードを載せていますので、記事を読んでくださった皆様のチームでもご活用いただけると幸いです。

Appendix

ワンショットタスク用の Step Functions の Terraform サンプルコード

resource "aws_sfn_state_machine" "enjoy_ecs_run_task" {
  definition = jsonencode({
    StartAt : "RunECSTask",
    States : {
      RunECSTask : {
        Type : "Task",
        Resource : "arn:aws:states:::ecs:runTask.sync",
        Parameters : {
          Cluster : “実行する ECS クラスターの arn”,
          TaskDefinition : “実行したい ECS タスク定義の arn”,
          LaunchType : "FARGATE",
          NetworkConfiguration : {
            AwsvpcConfiguration : {
              SecurityGroups : [“ECS タスクを実行する際の SecurityGroup”],
              Subnets : [“ECS タスクを実行する際の Subnets”]
            }
          },
          Overrides : {
            ContainerOverrides : [
              {
                Name : "ecs-task-run",
                "Command.$" : "$.commands"
              }
            ],
          }
        },
        End : true
      }
    }
  })
  role_arn = aws_iam_role.enjoy_ecs_task_run.arn
  name     = "enjoy-ecs-task-run"
  type     = "STANDARD"
}

data "aws_iam_policy_document" "assume_role_step_functions" {
  statement {
    actions = ["sts:AssumeRole"]
    effect  = "Allow"
    principals {
      type        = "Service"
      identifiers = ["states.amazonaws.com"]
    }
  }
}

data "aws_iam_policy_document" "enjoy_ecs_task_run" {
  statement {
    effect = "Allow"
    actions = ["ecs:RunTask"]
    resources = [“実行したい ECS タスク定義の arn”]
  }

  statement {
    effect = "Allow"
    actions = [
        "ecs:StopTask",
        "ecs:DescribeTasks"
    ]
    resources = ["*"]
  }

 statement {
    effect = "Allow"
    actions = [
      "events:PutTargets",
      "events:PutRule",
      "events:DescribeRule"
    ]
    resources = ["arn:aws:events:{$region}:{$accountID}:rule/StepFunctionsGetEventsForECSTaskRule"]
  }
}

resource "aws_iam_role" "enjoy_ecs_task_run" {
  name               = "enjoy-ecs-task-run-state-machine-role"
  assume_role_policy = data.aws_iam_policy_document.assume_role_step_functions.json
}

resource "aws_iam_role_policy_attachment" "enjoy_ecs_task_run" {
  role       = aws_iam_role.enjoy_ecs_task_run.name
  policy_arn = aws_iam_policy.enjoy_ecs_task_run.arn
}

resource "aws_iam_policy" "enjoy_ecs_task_run" {
  name   = "enjoy-ecs-task-run-state-machine-policy"
  policy = data.aws_iam_policy_document.enjoy_ecs_task_run.json
}

社内npm packageをRenovateで更新する方法

こんにちは、 Classi でソフトウェアエンジニアやってます koki です。

この記事では、 Renovate によって Classi の社内向け npm package を自動アップデートさせるために行った設定についてまとめます。

概要

Classi では、社内向けの共通ライブラリや Docker イメージなどを GitHub Packages で管理しています。 この GitHub Packages の利用により、それらのプライベートなパッケージを社内の様々なシステムから安全且つ効率的に利用することを実現しています。
例えば、今年の 6 月にプレスリリースが出された学習トレーニング機能を裏で支えているコンテンツ管理システムで利用している GraphQL Schema は GitHub Packages の npm registry でプライベートな npm package として管理しています。 こちらについて詳しくは下記記事をご参照ください。

また、各システムの GitHub リポジトリでは依存ライブラリの自動アップデートのために Renovate を利用しています。 下記記事では Renovate が作成したライブラリアップデートの PR のマージを自動化した仕組みについても解説していますので、よければこちらもご参照ください。

Renovate はデフォルトではプライベートな GitHub Packages への参照権限を持っていません。 そのため、社内向け npm package に依存しているリポジトリの Renovate Dashboard には次のような Warning が表示されます。

Renovate failed to look up the following dependencies: Failed to look up npm package <パッケージ名>

上記の Warning を解消するための Renovate の設定をしていきます。

設定手順

Renovate 用の Personal Access Token ( Classic ) を発行する

Renovate にプライベートな GitHub Package を読み取らせるためには、まず Personal Access Token を発行する必要があります。 2023 年 10 月時点で Fine-grained token は GitHub Packages に対応していないため、トークンの種類は Classic を選択する必要があることに注意してください。

必要な権限は次の通りです。

  • repo
  • read:packages

Personal Access Token を暗号化する

先ほど発行した Personal Access Token は暗号化した上で設定ファイルに埋め込む必要があります。 下記ページを使用して Personal Access Token を暗号化することができます。 ( ここで暗号化したトークンは Renovate のみが復号できます。詳細については後述の「暗号化済みトークンをハードコーディングして大丈夫なの?」をご参照ください。 )

Renovate Encrypt の画面

項目
Organization GitHub Organization 名
Repository (Optional) 暗号化済みトークンを使用できるリポジトリ名
省略した場合は Organization 内の全てのリポジトリから暗号化済みトークンを使用できる
Raw Value Renovate 用の Personal Access Token ( Classic ) を発行する」手順で発行した Personal Access Token

それぞれ入力して Encrypt をクリックすると Encrypted value に暗号化されたトークンが表示されます。 この値は後ほど Renovate の設定に使用するため、控えておきます。

Renovate の設定を作成

続いて Renovate の設定ファイルを作成します。 次のような内容で renovate.json を作成します。

{
  "hostRules": [
    {
      "matchHost": "https://npm.pkg.github.com/",
      "hostType": "npm",
      "encrypted": {
        "token": "<ENCRYPTED_PERSONAL_ACCESS_TOKEN>"
      }
    }
  ],
  "npmrc": "@<ORGANIZATION_NAME>:registry=https://npm.pkg.github.com/"
}

この設定を GitHub に push して Renovate を再実行することで、 Renovate により GitHub Packages の npm package を自動アップデートできるようになります。

実際に作られた Classi の社内 npm package の更新 PR

設定ファイルを分ける場合

先ほど作成した設定ファイルは renovate.json ではなく別のファイルに分けたい場合もあると思います。 例えば、「Renovate の設定を作成」手順で作成した JSON を renovate-github-packages.json という名前で保存する場合、 renovate.jsonextends に次のように追記します。

 {
   "$schema": "https://docs.renovatebot.com/renovate-schema.json",
   "extends": [
     // ...その他の設定,
+    "local><ORGANIZATION_NAME>/<REPOSITORY_NAME>:renovate-github-packages",
   ],
   // ...その他の設定
 }
  • <ORGANIZATION_NAME> : GitHub Organization 名。
  • <REPOSITORY_NAME> : リポジトリ名。

こうすることで renovate.json から renovate-github-packages.json を読み込むことができます。 設定ファイルの共有方法について詳しくは下記ドキュメントをご参照ください。

Appendix

これらの設定は必要なくなるかも ( GitHub Packages を使用している場合 )

GitHub Packages への参照権限を自動で設定する機能が Renovate に追加されたようです。 これにより、この記事で紹介した手順は不要になるかもしれません。

ただしこれはあくまで GitHub Packages でパッケージを管理している場合なので、 npmjs.org 等のレジストリを使用してプライベートなパッケージを管理している場合は引き続きこの記事と同じような手順で適切な認証トークンを暗号化して設定ファイルに埋め込む必要があります。

暗号化済みトークンをハードコーディングして大丈夫なの?

先ほど紹介した下記ページを使用することで、 Personal Access Token は Renovate の公開鍵を使用して暗号化されます。

この方法で暗号化されたトークンは Renovate のみが復号できる = Renovate 用途でしか使えません。 暗号化済みトークンを使用できる Organization と Repository も制限できるので、暗号化済みトークン自体はもし漏洩しても大きな問題はありません。 特に Organization は必須項目なので、少なくとも組織外のリポジトリから使うことはできません。

とはいえ、万が一 Renovate 側に悪い人がいたり秘密鍵漏洩等のセキュリティインシデントが発生したりした場合は Personal Access Token が漏洩する可能性はもちろんあります。 そうなったら、かなり広い権限 ( 特に repo Scope ) を持っている Personal Access Token なので結構大変です。

なので「Renovate には専用の Personal Access Token を割り当てて他のものを流用しない」等の運用を行い、万が一何かがあったときにすぐ Revoke できるようにしておくことが大切です。

参考

Lambda Extensionと自家版OpenTelemetry Collector

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

以前、 実践OpenTelemetry - Classi開発者ブログ で紹介したようにOpenTelemetryを監視フレームワークとして導入しています。

前回の記事ではECSサービスで動くGoで書かれたWebアプリケーションへ導入しましたが、今回はAWS Lambdaの関数からOpenTelemetryを用いてDatadogにトレースを送るための試行錯誤について紹介します。

LambdaからDatadogにトレースを送りたい

AWS Lambdaは小〜中程度のタスクを大量に実行することに向いたFaaSです。

汎用性・柔軟性という点ではECSなどのコンテナワークロードと比べると実行環境などに設けられた制約がやや多いですが、その代わりに最適化された実行環境で実行されるのでうまくワークロードと噛み合えばまさに安い・早い・うまいの三拍子が揃うとても魅力的なサービスです。

ClassiでもLambdaを活用しており、本番環境で使うからには当然監視も充実させたいです。

冒頭の記事でも触れたようにフレームワークとしてはOpenTelemetryを、SaaSとしてはDatadogを活用しているのでLambda関数の分散トレースも当然OpenTelemetryを用いてDatadogへ送りたいです。

ECSサービスでホストされるアプリケーションの場合はOpenTelemetry Collectorをサイドカーコンテナとして実行し、アプリケーションからはサイドカーを通じてDatadogなりへシグナルを送ればよいのですが、既に述べたようにLambdaの実行環境は特殊で、Lambdaのエコシステムに沿った準備をしないといけません。

幸いながら現在Lambda ExtensionというLambdaのライフサイクルに沿って実行されるサイドカーのような仕組みが整えられており、一般的なコンテナワークロードとまったく同じ体験とはいきませんが、アプリケーション本体と補助的なソフトウェアをうまく分離することはできます。

まさにDatadog agentがLambda Extensionとして提供されていたり、OpenTelemetry CollectorがLambda Extensionとして提供されていたりと、これらがそのまま使えるとよさそうです。

しかし、それで済めば筆者は筆をとってこの記事を書いていないでしょう。つまり我々がやりたいことを実現するには一筋縄ではいかないのが現状なのです。

具体的には Lambda関数からOpenTelemetry SDKを用いてDatadogへトレースを送る という3つの要素要求すべてを満たすにはありものでは賄えないのです。

言い換えると (「Lambda関数から」という点はともかく) SDKかDatadogのどちらかを諦めれば実現できます。

しかしOpenTelemetry SDKさえ使えば複数のライブラリを使わなくて良いという魅力に惹かれて導入・移行したので今更後戻りはしたくありません。Lambdaの監視だけDatadogではない別のSaaSを使うというのもいざ障害などに遭遇した時に不安が残ります。

なので:

  • Lambda関数から
  • OpenTelemetry SDKのみを用いて
  • Datadogへトレースを送る

……という3点すべてを満たすべく格闘していきます。

Lambda Extensionについて

改めてLambda Extensionについて述べます。

Lambda Extensionは関数本体とは別にLambdaのライフサイクルイベントごとに実行される補助的なソフトウェアです。

たとえば関数の起動時に初期化処理を走らせ、関数の呼び出しごとにデータを集めるなどし、関数がコールド状態に移行する時に集積したデータを送信する……といったことができます。

リリースと同時にいくつかのSaaSがLambda Extensionを発表しており、その中にはDatadogのextensionもあります。

関数の中の所定のディレクトリに実行ファイルとして配置され、それがLambdaによって都度呼び出されるというかたちになっているため、デーモンのようには振る舞えないのでデーモンとして実行することを想定しているソフトウェアは実装を変える必要があります。

AWS Distro for OpenTelemetry (ADOT) とopentelemetry-lambdaについて

OpenTelemetry Collector (以下、OTel Collector) をLambda Extensionとして実行する方法も既にあり、それがAWS Distro for OopenTelemetry (ADOT)です。

ADOTはAWSがサポートするOTel Collectorのディストリビューションです。LambdaやECSなど各環境で公式にサポート・推奨するexporterなどをあらかじめ有効にしたビルド済みのOTel Collectorです。 また、ビルド済みのLambda ExtensionもADOTプロジェクトの一部として提供されています。

ADOTが提供するLambda ExtensionはExtensionのプロトコルを喋りOTel Collectorを動かすラッパとして振る舞うopentelemetry-lambdaという別リポジトリにパッチを当ててビルドしたものです。

opentelemetry-lambdaはopentelemetry org.がオーナーであり、Lambda Extensionプロトコルを喋る実装はopentelemetryプロジェクトが責任を持ち、配布するソフトウェアとしてはAWSが別にオーナーシップを持っているというモデルのようです。

至れり尽くせりなプロジェクトがあるのでその恩恵に与れたらよいのですが、なんとこのADOTにはdatadogexporterが含まれていません。 つまり、ADOTを使ってDatadogへトレースやメトリクスを送れません。

X-Rayなど競合するサービスを提供しているという事情も想像できますが、それ以外にdatadogexporterの初期化処理に時間がかかりLambdaのInitフェイズでタイムアウトするというissueが過去に報告されており、それも関係しているのかもしれません。

いずれにせよADOTを用いてDatadogへトレースを送ることはできないことがわかりました。

Lambda ExtensionおよびOpenTelemetry Collectorの自前ビルド

ADOTはopentelemetry-lambdaにパッチを当ててビルドしているだけと説明しました。ということはdatadogexporterを使えるようパッチを当ててビルドすればLambdaからDatadogへトレースを送れそうです。

OTel Collectorはビルド済みのディストリビューションを使うほか、自分でディストリビューションを作ることもでき、その方法やツールも公式にまとめられています

最終的に作りたいのはLambda Extensionなので「『datadogexporterを含むOTel Collector』を含むLambda Extensionをビルドする」ことが目標です。

言葉にするとややこしいですが、要はADOTであてているパッチを変えてビルドし、その結果をホストできればよいです。

そしてできあがったopentelemetry-lambdaの差分が次のURLです: https://github.com/open-telemetry/opentelemetry-lambda/compare/main...aereal:opentelemetry-lambda:support-datadog-exporter

続いて変更内容について詳しく説明します。

datadogexporterを追加

今回の本命となる変更です。

datadogexporterがLambda Extension向けにそもそもコンパイルできないといった事情はないので、単にimportしてファクトリメソッドに渡すだけです。

Parameters Store confmap providerを追加

実用上、必要になって追加した変更です。

LambdaはECSなどと異なり環境変数のデータソースとして直接Parameters StoreやSecrets Managerなど秘匿情報を安全に伝達できるサービスがサポートされておらず、関数の設定に直接値を記述するほかありません。

datadogexporterを使うにはOTel Collectorの設定にDatadogのAPI keyを渡す必要があり、これは当然ながら秘匿情報です。

Lambda関数で秘匿情報を扱うにはParameters StoreやSecrets Managerなどへ関数内から直接アクセスするほかありませんが、今回サービスを通じて秘匿情報を得たいのは関数本体ではなくLambda Extension内で動くOTel Collectorです。

設定ファイルにParameters Storeへアクセスするコードを書けるわけもないのでconfmap providerを介して取得できるようカスタマイズしました。

confmapとは環境変数やHTTPエンドポイントなど、外部データソースから値を設定に埋め込む仕組みです。 各データソースごとの実装がconfmap providerと呼ばれます。

OTel Collectorは標準で環境変数のconfmap providerが組み込まれているので、 ${env:DD_API_KEY} のように書くとOTel Collectorの起動時に環境変数を参照して設定が補完されます。

Parameters Storeからパラメータを取得し、値を展開するconfmap providerを実装しそれを使うことで設定ファイルに ${ssm:/path/to/datadog-api-key} のような記述をして、適切なIAM許可を与えておけばOTel CollectorにAPIキーを渡せます。

使ったconfmap providerの実装はこちらです: https://github.com/aereal/otel-confmap-provider-awsssm

Lambda Extensionをビルドし、GitHub Packagesでホストする

https://github.com/aereal/otel-collector-dist

最後にopentelemetry-lambdaのforkをビルドします。ADOTを参考にExtensionをGitHub Actionsでビルドするリポジトリを別に用意しました。

opentelemetry-lambdaのfork自体は随時上流と同期しないといけないので、差分を減らすために別リポジトリにしています。

ビルドしたExtensionを含むコンテナイメージもビルドし、GitHub Packagesでホストするようにしました。 GitHub Actionsと連携する上で便利ですし、更に社内用ビルドを社内のinternal/privateなリポジトリから参照する際のアクセス許可も簡単に設定できます。

むすび

Datadogへトレースを送れるようカスタマイズしたOpenTelemetry CollectorをLambda Extensionとしてビルドする方法とOpenTelemetry Collectorにまつわるエコシステムについて紹介しました。

筆者もそうだったのですが、初見では様々な概念が登場するため混乱しがちですが落ち着いて整理するときれいに依存が分離されていますし、それぞれのエコシステムも十分な情報がまとまっているため、自家ビルドも簡単にできました。

自家版ビルドを作り運用することはもちろん運用の負荷が増すことになるので、いずれ上流で解決できたらいいなと思いますしそのように働きかけたいと思いますが、それはそれとしてOpenTelemetry Collectorのエコシステムの理解を深めるいいきっかけになりました。

みなさまもぜひ自家版ビルドをしてみてはいかがでしょうか。

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

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

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

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

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

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

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

作ったもの

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

作る際に詰まったところ

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

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

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

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

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

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

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

GitHub アカウントの種類

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

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

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

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

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

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

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

permissions:
  packages: read

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

まとめ

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

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

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

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

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

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

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

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

問題集編成のアプローチ

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

理想の難易度分布例

モデルの詳細

定式化

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

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

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

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

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

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

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

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

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

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

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

数値実験結果

PoCの実施

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

最後に

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

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

実践OpenTelemetry

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

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

OpenTelemetryとは

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

What is OpenTelemetry?

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

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

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

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

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

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

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

Classiで採用した理由

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

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

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

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

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

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

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

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

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

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

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

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

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

属性の管理

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

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

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

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

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

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

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

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

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

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

telemetry.schema.json

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

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

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

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

生成されたGoのコード

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

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

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

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

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

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

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

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

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

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

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

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

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

generate-signals/main.go

package main

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

    "gopkg.in/yaml.v3"
)

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

    pkg        string
    typeName   string
    doFormat   bool
    configPath string
    outPath    string

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

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

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

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

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

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

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

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

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

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

type measurement struct {
    Name     string
    DataType string
}

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

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

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

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

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

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

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

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

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

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

src.gotpl

package {{ .Package }}

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

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

var emptyKeyValue attribute.KeyValue

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

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

// measurements

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

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

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

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

むすび

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

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

© 2020 Classi Corp.