Classi開発者ブログ

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

ISUCON11予選課題の27万点まで練習し新人エンジニアが学んだこと

この記事は Classi developers Advent Calendar 2021 の23日目の記事です。

こんにちは、プロダクト開発部の2年目の@minhquang4334です。 今年の8月に、同じ部で3年目の@henchiyb 先輩と一緒に yasuoチームを作り、ISUCON11 オンライン予選に初めて参加しました。参加するきっかけは弊社に業務委託として来てくださっている@soudaiさんからISUCONの話について聞かれて、面白そうなので、チャレンジしてみました。結果はRubyで4万点まで達成できましたが、全体のチームの100/598 位ぐらいで敗退してしまいました。

オンライン予選が終わった後、数百万点を達成したチームはどうやってそこまで出来たのかとずっと疑問でした。各チームの解説ブログを見てみましたが、目を通しただけですぐ忘れてしまい、知見を深く理解できないと思いました。それで、自分でももっと深くやってみたいという気持ちになったので、ISUCON 運営の方が準備してくれた環境を立ち上げて練習しました。再度の結果はRubyで、27万点 (11/598 位の点数相当) まで伸ばせました。

ISUCON11予選の課題の練習を通じて、Webパフォーマンスチューニングを中心に、いろいろ勉強になりました。その知見は新人エンジニアの時とISUCONに初めて参加する時に早めに知っておくと良さそうだと思ったので、この記事ではそんなエンジニアを対象にISUCON11予選課題の解説を含めて、自分なりの学んだことをまとめて共有します。

前提

推測ではなく測定しよう

パフォーマンスチューニングするとき、どのように進めていますか。今までは、私はよくコードを見て、N+1や不要なループなどといったパフォーマンスの悪いコードを気づいたら、すぐ改善しようと思いました。しかし、ISUCONでそのように時々たくさん改善できても、システムのパフォーマンスが上がらなく、点数もほぼ変わっていません。 理由は解決した問題はシステムのパフォーマンス低下の原因ではないからです。それも、問題を特定するために、システムのメトリクスを見ておらず、自分の推測を信じたからです。 例えば、以下のAPIの実行時間フレームグラフの場合、どれほど 不要なループとN+1を改善しても、そのAPI自体における改善の効果は少ないのではないでしょうか。不要なループとN+1は良くない実装ですが、パフォーマンス低下の原因ではありません。

f:id:minhquang4334:20211222190216p:plain
例:リクエストの実行時間

正しい問題を調べるのがすごく大事で、問題解決のアプローチを変更しなくてはいけません。推測の代わりに、システム運用のメトリクスを監視したり、測定したり、異常なものを問題として抽出したりすることです。そうして適切な問題を取り除き、解決してから、再度その測定・改善プロセスを回します。

ISUCON11予選練習の時に、その測定・改善プロセスを回してみると、徐々に点数が上がってきて、やりながら達成感も感じられました。

測定した例です。

  • ALPを使って、リクエストごとに実行時間を測定する
+-------+--------+------------------------------+-----+------+-----+-------+-----+-------+-------+----------+-------+-------+-------+-------+--------+-----------+------------+--------------+-----------+
| COUNT | METHOD |             URI              | 1XX | 2XX  | 3XX |  4XX  | 5XX |  MIN  |  MAX  |   SUM    |  AVG  |  P1   |  P50  |  P99  | STDDEV | MIN(BODY) | MAX(BODY)  |  SUM(BODY)   | AVG(BODY) |
+-------+--------+------------------------------+-----+------+-----+-------+-----+-------+-------+----------+-------+-------+-------+-------+--------+-----------+------------+--------------+-----------+
| 32656 | POST   | /api/condition/*             |   0 | 7510 |   0 | 25146 |   0 | 0.004 | 0.320 | 3067.503 | 0.094 | 0.060 | 0.100 | 0.100 |  0.018 |     0.000 |     14.000 |       28.000 |     0.001 |
|  2688 | GET    | /isu/*                       |   0 | 2561 |   0 |   127 |   0 | 0.048 | 0.956 |  549.019 | 0.204 | 0.000 | 0.472 | 0.112 |  0.198 |     0.000 | 135259.000 | 42280309.000 | 15729.282 |
|   639 | GET    | /api/trend                   |   0 |  614 |   0 |    25 |   0 | 0.024 | 1.116 |  397.817 | 0.623 | 0.068 | 0.540 | 0.700 |  0.195 |     0.000 |   4637.000 |  2749921.000 |  4303.476 |
+-------+--------+------------------------------+-----+------+-----+-------+-----+-------+-------+----------+-------+-------+-------+-------+--------+-----------+------------+--------------+-----------+
Count: 68432  Time=0.00s (28s)  Lock=0.00s (0s)  Rows_sent=46.7 (3194156), Rows_examined=397.0 (27169268), Rows_affected=0.0 (0), isucon[isucon]@localhost
  SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S' ORDER BY timestamp DESC

Count: 1089  Time=0.00s (2s)  Lock=0.00s (0s)  Rows_sent=0.0 (0), Rows_examined=0.0 (0), Rows_affected=0.0 (0), isucon[isucon]@localhost
  COMMIT

Count: 1152  Time=0.00s (1s)  Lock=0.00s (0s)  Rows_sent=1.0 (1125), Rows_examined=2725.9 (3140278), Rows_affected=0.0 (0), isucon[isucon]@localhost
  SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S' ORDER BY `timestamp` DESC LIMIT N

Count: 21800  Time=0.00s (1s)  Lock=0.00s (0s)  Rows_sent=3.1 (68432), Rows_examined=0.4 (8204), Rows_affected=0.0 (0), isucon[isucon]@localhost
  SELECT * FROM `isu` WHERE `character` = 'S'

Count: 3377  Time=0.00s (0s)  Lock=0.00s (0s)  Rows_sent=0.0 (0), Rows_examined=0.0 (0), Rows_affected=1.0 (3377), isucon[isucon]@localhost
  INSERT INTO `isu_condition` (`jia_isu_uuid`, `timestamp`, `is_sitting`, `condition`, `message`) VALUES ('S', 'S', TRUE, 'S', 'S')
  • top コマンドでCPU 利用率とメモリ利用率を測定する
  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
   2244 isucon    20   0  784600  74604  12984 S 110.6   2.0   0:24.22 bundle
   2077 mysql     20   0 1714832 109468  18660 S  25.9   2.9   0:29.95 mysqld
   2236 www-data  20   0   13792  10416   5036 S   6.6   0.3   0:01.92 nginx
   2235 www-data  20   0   11428   7992   5036 R   3.3   0.2   0:00.90 nginx

もしDatadogやNew Relicなどが活用できたら、測定がより楽になると思います。

データベースについて学んだこと

Webシステムの世界ではデータベース がパフォーマンス低下の原因となるケースは少なくないようです。それで、ソフトウェアエンジニアとして、データベースの正しい扱い方を学ぶのは損ではないと思います。

LIMIT句が大事だ

You aren't gonna need it https://ja.wikipedia.org/wiki/YAGNI

実際に必要となるまでは追加しないのがよいというプログラミング原則があります。そのような考え方でデータベースからデータを取得する時にも、必要であるデータのみを取得するのが大事です。 LIMIT 句を付けたら、 SELECT 文を実行した時に取得するデータの行数の上限を設定することができます。LIMIT 句を付けないと、無駄なデータを取得し、SQLが実行される時間のほとんどがDisk I/Oになってしまって、CPU利用率なども上がって、システムのボトルネックになるケースが多いです。 ISUCON 11で不要なデータを取得しないようにLIMIT 句を付けたら結構パフォーマンスが上がって、点数も急増できました。正に小さい変更で大きな効果です。

  • LIMIT句を付ける前 MySQL Slow Log
Count: 75480  Time=0.00s (2s)  Lock=0.00s (0s)  Rows_sent=46.9 (3537598), Rows_examined=3.0 (228652), Rows_affected=0.0 (0), isucon[isucon]@localhost
  SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S' ORDER BY timestamp DESC
  • LIMIT句を付ける後 MySQL Slow Log
Count: 226516  Time=0.00s (8s)  Lock=0.00s (1s)  Rows_sent=1.0 (223303), Rows_examined=0.1 (29024), Rows_affected=0.0 (0), isucon[isucon]@localhost
  SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'S' ORDER BY timestamp DESC LIMIT N

LIMIT句を付ける後、Rows_sent と Rows_examined の数値が大きく減らすことができました。見なきゃいけないデータが限定されて、データベースの負荷も大きく減らせます。以下は実装・測定の参考となります。

Auto Increment Primary Key を使うべきか

Primary Key カラムにAUTO_INCREMENT をつけると、データを追加した時にカラムに対して現在格納されている最大の数値に 1 を追加した数値を自動で格納することができます。カラムに連続した数値を自動で格納したい場合に便利ですが、パフォーマンス的に問題ないでしょうか。MySQLの前提で考えてみましょう。

MySQLでB-treeインデックスの構造とInnoDBストレージエンジンを使われています。B-treeは以下の写真のような木のデータ構造で、テーブルのIndex (Primary Keyも一つのIndex) をテーブルのデータと別途に管理するものです。テーブルに一つのレコードを追加したら、テーブルにあるIndexのB-treeにも一つのノードが追加されます。B-treeの詳しい解説はこちらをご覧ください。

f:id:minhquang4334:20211222190032p:plain
B-tree インデックス構造

B-treeにノードが追加されるときに、どこで保存できるか既存のノードからそれぞれ比較します。それで、Primary KeyにAUTO_INCREMENTをつけたら、Indexは連続した数値なので、すぐ保存できるポジション (一番右側の下)を見つけられます。その特徴からAUTO INCREMENT Primary Keyを使ったら、Insert文が早く大量のInsert文によって負荷が高くなるシステム (write-heavyシステム) に相応しいと思っている方は多いのではないでしょうか。 しかし、InnoDBではACID特性)を守るため、AUTO_INCREMENT ロックモードがあります。それはテーブルにレコードを書き込むときに、AUTO_INCREMENTのロックをとって、他の書き込むトランザクションが待機させるという仕組みです。

f:id:minhquang4334:20211222190307p:plain
Mysql AUTO_INCREMENT ロックの例

上記の写真のように、同時に複数のトランザクションからAUTO INCREMENT Primary KeyがあるテーブルにInsert文が発行されたら、T1が終わるまで、T2がロックされます。T1が終わったら、T2でロックが 解放されて、T2はAUTO_INCREMENTロックを取得できて、Insert文が発行できます。そのため、大量のInsert文の負荷があるシステムなら、AUTO INCREMENT Primary Keyを使う場合、副作用が出てくる可能性があり、パフォーマンス低下の原因の一つになります。

ISUCON11でIOTシステムみたいな時系列のデータを収集するシステムなので、AUTO INCREMENT Primary Keyのテーブルに同時に大量のInsert文が発行されました。そのテーブルのPrimary KeyをCompound Keyに変更したら、結構パフォーマンスが改善できました。

システムアーキテクチャについて学んだこと

一個一個のAPIやリクエストやクエリなどを改善するというより、システム全体の構成に問題があるのか特定して解決できたら、ISUCONだけではなく実際にシステムのパフォーマンスを最適化するうえで大きなカギになると思います。

コンテンツ配信はNGINXに肩代わりさせる

普通のWeb システムのクライアントに静的なファイルを配信する方法は root/ フォルダーにファイルを置いておき、必要な場合はバックエンドがユーザーの認証やファイルを読み取りをしてから、Webサーバーが配信します。 Rubyの場合は、send_fileというメソッドがよく使われています。

railsdoc.com

指定したパスに存在する画像やファイルを読み込み、その内容をクライアントに送信

send_fileの動きが気になったことはありませんか。これは、ディスクからファイルを読み取る必要があることです。このファイルは、出力バッファーを通過し、Webサーバーにフラッシュされ、クライアントに配信します。大きいファイルの場合、メモリをたくさん使って、memory_limitを超えたケースもあります。ファイル全体をメモリに保存したり、削除したりするプロセスのため、バックエンドが忙しくなって、他のリクエストを処理できなくなります。

そこで、バックエンドの負荷を減らすために、X-Accel-Redirectを使って、認証のみをバックエンドで行い、コンテンツ配信はNginxに肩代わりさせることができます。X-Accel-RedirectはNginxの機能で、バックエンドから返されたヘッダによって決定される場所への内部的なリダイレクトを許可します。それでバックエンドを解放して他のリクエストを処理でき、Nginxが持つ素晴らしいコンテンツ配信の力で、高速配信を実現できます。

f:id:minhquang4334:20211222190559p:plain
X-Accel-Redirectを使った場合

ISUCON11予選を練習するときに、その機能は全く知らず、解説しているブログなどをいくつも読むことで勉強になりました。以下のようにnginx設定ファイルに設定しました。

https://github.com/minhquang4334/isucon11-training/blob/master/isucondition.conf#L64-L68

    location /icon {
        internal;
        alias /home/isucon/tmp; 
        expires 86400s;
    }

location /iconinternalを追加したら、X-Accel-Redirect レスポンスヘッダーを返すことでnginxから静的ファイルをクライアントに配信できます。

リバースプロキシキャッシュを用意する

キャッシングはWebパフォーマンスの最適化最適化におけるポピュラーな手法の一つです。現代のWebシステムにキャッシングシステムはなくてはならない存在になったといった印象もあります。それを用意することで、Webサーバとかデータベースなどの負荷が減らすことができます。 リバースプロキシキャッシュはキャッシングの一つのタイプであり、Webサーバのクライアントの間に置いておくサーバです。その中で、Varnishは一番よく使われているものです。

f:id:minhquang4334:20211222190744p:plain
Varnishはリバースプロキシキャッシュサーバーである

上記の図を使って、Varnishといったリバースプロキシキャッシュを簡単に説明します。

  • 一度目にクライアントからリクエストを送ったら、VarnishはWebサーバにフォワードします。Webサーバはそのリクエストを処理して、データベースや外部APIと接続してから、レスポンスを生成します。そのレスポンス自体はVarnishのキャッシュに保存されて、クライアントに返します。
  • 2度目以降でクライアントからリクエストを受けたら、すでにレスポンスはVarnishのキャッシュにあるので、すぐ返します。
  • Varnishのキャッシュの有効期限も設定でき、有効期限を超えたら、キャッシュにあるレスポンスは無有効に設定されます。

そのフローでバックエンドコードを変更せずに、負荷が凄く減らせます。この仕組みは特に認証とリアルタイムも必要なくページやデータなどに相応しいと思います。

ISUCON11予選の課題では、ユーザーがログインせずサマリの画面をリロードする度にapi/trendが呼ばれて、かなり重いクエリが発行されてしまいます。そのページが遅くて、ユーザーがサイトに興味がなくなるというビジネスの要件もあります。そこで解説ブログを参考してみてから、Varnishをインストールして0.5sの有効キャッシュ応答をするように変更しました。

  • Varnishを活用する前の該当リクエストのALP
+-------+--------+------------------------------+-----+------+------+-------+-----+-------+-------+----------+-------+-------+-------+-------+--------+-----------+------------+---------------+-----------+
| COUNT | METHOD |             URI              | 1XX | 2XX  | 3XX  |  4XX  | 5XX |  MIN  |  MAX  |   SUM    |  AVG  |  P1   |  P50  |  P99  | STDDEV | MIN(BODY) | MAX(BODY)  |   SUM(BODY)   | AVG(BODY) |
+-------+--------+------------------------------+-----+------+------+-------+-----+-------+-------+----------+-------+-------+-------+-------+--------+-----------+------------+---------------+-----------+
|  4499 | GET    | /api/trend                   |   0 | 4482 |    0 |    17 |   0 | 0.004 | 0.180 |  542.084 | 0.120 | 0.080 | 0.116 | 0.124 |  0.015 |     0.000 |   5870.000 |  25317465.000 |  5627.354 |
+-------+--------+------------------------------+-----+------+------+-------+-----+-------+-------+----------+-------+-------+-------+-------+--------+-----------+------------+---------------+-----------+
  • Varnishを活用する後の該当リクエストのALP
+-------+--------+------------------------------+-----+------+------+-------+-----+-------+-------+----------+-------+-------+-------+-------+--------+-----------+------------+---------------+-----------+
| COUNT | METHOD |             URI              | 1XX | 2XX  | 3XX  |  4XX  | 5XX |  MIN  |  MAX  |   SUM    |  AVG  |  P1   |  P50  |  P99  | STDDEV | MIN(BODY) | MAX(BODY)  |   SUM(BODY)   | AVG(BODY) |
+-------+--------+------------------------------+-----+------+------+-------+-----+-------+-------+----------+-------+-------+-------+-------+--------+-----------+------------+---------------+-----------+
| 24461 | GET    | /api/trend                   |   0 | 24454 |   0 |     7 |   0 | 0.000 | 0.020 |    6.500 | 0.000 | 0.000 | 0.000 | 0.000 |  0.001 |      0.000 |   8478.000 | 159572757.000 |   6523.558 |
+-------+--------+------------------------------+-----+------+------+-------+-----+-------+-------+----------+-------+-------+-------+-------+--------+-----------+------------+---------------+-----------+

Varnishを使った後、AVG, P1, P50, P99のレスポンスタイムはほぼ0sぐらいになりました。バックエンドにほぼ手を入れずに、簡単な修正で、信じられないパフォーマンス最適化が出来て、すごく感動しました。今回、Varnishを導入するのが以下となります。

location ~ ^/api/trend$ {
    proxy_set_header Host $http_host;
    proxy_set_header Connection "";
    proxy_http_version 1.1;
    proxy_pass http://varnish;
}
sub vcl_backend_response {
    # Happens after we have read the response headers from the backend.
    #
    # Here you clean the response headers, removing silly Set-Cookie headers
    # and other mistakes your backend does.
    set beresp.ttl = 0.5s;
    set beresp.grace = 0.2s;
}

課題の特徴から適切な構成を設計する

ISUCON11予選の課題の特徴として、時系列のデータでありユーザーが増加することにより同時に大量のレコードをデータベースに書き込むといった特性上、どうしてもデータベースの負荷が高くなりパフォーマンスが低下してしまいます。そこで、リクエストごとにデータベースに書き込まずに、書き込むデータをキャッシューに入れ、Scheduled Jobを起動し、定期にキャッシュから取得したデータを一括データベースに書き込むように修正しました。その修正をした後、点数は136226から222174まで急増できました。以下のリンクは今回の修正と測定となります。 https://github.com/minhquang4334/isucon11-training/issues/1#issuecomment-944838242

こちらの改善はパフォーマンスの観点で一番効果が高いと感じています。解決したい課題の特徴を把握して、適切な構成を設計して、実装するのが一番大事かと勉強になりました。それは最初から書いた通りに、正しい課題を解決することだと思います。

終わりに

今回の練習を通じて、Webシステムパフォーマンスチューニングを含めて技術の課題解決についていろいろ勉強になりました。目を通しただけで忘れてしまい、実際にやってみた方がより深く理解できてきたと思っています。その知見を生かして、これからClassiでの業務にも頑張っていきたいです。それとも、今年の予選では敗退してしまいましたが、来年の予選までちゃんと練習して、本選まで挑戦していきたいと思います。💪 💪

自分自身の学んだことや理解したことをこの記事にまとめました。理解が不足している部分もあるかと思います。お気づきの際はぜひコメントしていただきたいです。

明日のClassi developers Advent Calendar 2021の担当は平田哲也さんです。お楽しみに。

トピックモデルを使って問い合わせ内容を分析した話

この記事はClassi developers Advent Calendar 2021の18日目の記事です。
昨日は基盤インフラチームのめるさんによる「バックエンドエンジニアが基盤インフラチームに異動して半年ほど経った話」でした。

こんにちは、データAI部でデータサイエンティストをしている高木です。
弊社では顧客である先生、生徒、保護者からClassiの機能や契約に関する問い合わせを日々頂いております。
これらの問い合わせの内容を分析し、Classiの現状の課題や今後解決していくための施策などを社内で検討しています。

今回は問い合わせ内容を言語処理技術の一つであるトピックモデルを使って分析した内容についてご紹介します。

なぜ分析する必要があったのか?

Classiへの問い合わせやその対応の内容は、担当者によってテキスト化された状態で管理されています。
弊社のカスタマーサポート・カスタマーサクセスチームは、これらの内容を基に次年度の施策を検討したり、実施した施策がどうだったかという効果を分析しています。

年度が変わる3月〜4月での問い合わせが増加傾向にあり、その中でも学校全体の利用に関わる設定・登録の問い合わせが最も多くなっています。
毎年、これらの問い合わせを目視でチェック・分類し、施策を検討している状態だったのですが、作業には膨大な時間と労力がかかってしまいます。

そこで、ある程度自動的に分析・分類できないかという必要性が出てきており、今回トピックモデルを使った分析を試みました。

トピックモデルとは?なぜ使ったのか?

トピックモデルは、文書集合をそれらに含まれる単語の共起性から、どんなトピック(話題や分野など、大体の「意味」のようなもの)が存在するのか、各文書はどのトピックについてのものか、を自動で推定することができます。

これまで様々な手法が提案されており、文書の検索や分類、また、音楽の歌詞探索*1やゲームのデッキアーキタイプの抽出*2など、様々な分野へ応用されています。
私もテスト問題に応用した研究をしています*3

以下は代表的なトピックモデルです。

トピックモデル 特徴 参考文献 参考コード
LSI: Latent Semantic Indexing ・特異値分解による文章の圧縮
・1文書に1トピック
Deerwester et al (1990)*4 Gensim
PLSI: Probabilistic Latent Semantic Indexing ・LSIの確率モデル化
・1文書に複数トピック
Hofmann (1999)*5 PyPI
LDA: Latent Dirichlet Allocation ・PLSIのベイズ化
・新規文書のトピック推定が可能
Blei et al (2003)*6 Gensim
HDP: Hierechical Dirichlet Process ・LDAのノンパラメトリック化
・トピック数が自動決定可能
Teh et al (2006)*7 Gensim
DTM: Dynamic Topic Model ・時系列データへの適用 Blei et al (2006)*8 Gensim
BTM: Biterm Topic Model ・短い文章への適用 Yan et al (2013)*9 GitHub

一方で、問い合わせの内容にはClassiの機能や契約に関する複数の質問が同時に含まれることがあります。
そして、その内容は時期によっても変化します。

そこで今回は、(1)自動で手軽に内容を把握できる、(2)同一の問い合わせから複数のトピックを抽出することができる、(3)内容の時系列変化を追うことができる、 という点を考慮しDTM(Dynamic Topic Model)を使用しました。

下図はDTMによるトピックの推定と時系列変化の例です。
時刻tにおける、ある文書集合のトピックに出現する単語(横軸)と、それらの単語がトピックに出現する確率(縦軸)を表しています。
また、各文書でそれぞれのトピックが出現する確率も推定されます。

f:id:ttakagi1021:20211216200222p:plain
DTMによるトピックの推定と時系列変化のイメージ

DTMによる問い合わせ内容の分析

概要

2020年と2021年の3月〜4月に寄せられた設定・登録についての問い合わせをトピック分析し、内容の変化を考察しました。
DTMを使ったトピック分析の手順と目的は以下になります。
なお、すべての分析はPythonで行いました。

  1. DTMによるトピックの推定
    • 2020年と2021年のそれぞれの問い合わせのトピックとその時系列変化を推定するため
  2. トピックへのラベル付け
    • トピックの内容を把握しやすくするため
  3. トピックごとの問い合わせ件数を集計
    • どのトピックの問い合わせが多いか把握するため
  4. 2020年と2021年のトピック間類似度の計算
    • 類似度の低いトピック(各年の特有なトピック)を抽出するため
  5. ダッシュボードの作成
    • 手順1〜4の結果を可視化するため

分析手順の詳細

1. DTMによるトピックの推定

DTMによるトピックの推定は、Gensimのmodels.ldaseqmodelというライブラリを使用しました。
トピックの推定で使用するメソッドと入力パラメータは以下になります。

dtm_model = LdaSeqModel(corpus, id2word, time_slice, num_topics)
  • corpus:コーパス
    • 文書毎の単語IDとその出現回数を持つタプルリスト
  • id2word:辞書
    • 単語とそれを一意に識別するIDの辞書
  • time_slice:タイムスライス
    • 指定した「期間」ごとの文書数のリスト
  • num_topics:トピック数
    • トピックの数は可変

辞書やコーパスの詳細については省略しますが、これらを作成する際に重要となる単語の抽出処理について後述します。

タイムスライスとは、トピックの変化を指定した「期間」で分割するためのもので、今回はその「期間」を月曜日〜日曜日の1週間ごとにしました。
週毎の問い合わせ件数が以下の場合、time_slice = [3, 5, 1]となります。

  • 2021/03/01(月)〜03/07(日):3件
  • 2021/03/08(月)〜03/14(日):5件
  • 2021/03/15(月)〜03/21(日):1件

さらに、トピック数は分析や分類したい粒度によって手動で設定する必要があります。
今回は以下の理由で両年ともトピック数を10としました。

  • 2020年の問い合わせを調査した際、約10個の重要なカテゴリに分類された
  • 多すぎるとトピックの意味が細かく分散しチェックするのが大変

単語の抽出処理について

トピックモデルではトピックの推定精度を向上させるために、関連のある単語の共起性を高めることが重要になってきます。
一方で、日本語には次のような性質があります。

単名詞Nが対象分野の重要な概念を表しているなら、書き手はNを頻繁に単独で使うのみならず、新規な概念を表す表現としてNを含む複合名詞を作りだすことも多い。*10

この「単名詞N」と「単名詞Nを含む複合名詞」は関連する概念を表していることが多いです。

例えば、Classiのコミュニケーション機能である「校内グループ」では、「グループ」という単語を中心に、「自動作成グループ」、「任意作成グループ」などの機能が存在し、同一の問い合わせ内で出現することが多いです。

この例の場合、「単名詞N」と「単名詞Nを含む複合名詞」は以下になります。

  • 単名詞:校内、グループ、自動、作成、任意
  • 複合名詞:校内グループ、自動作成グループ、任意作成グループ

これらの単語はすべて関連しており、同一のトピックに分類されることが望まれるため、このような単名詞複合名詞をすべて抽出するようにしました。
抽出処理には、形態素解析器のMeCabと形態素解析器の結果を基に複合名詞を抽出することが可能なTermExtractを使用しています。

2. DTMによるトピックの推定

手順1で作成されたdtm_modelにより、特定の期間のトピックに出現する単語と出現確率をリストで出力することができます。
そのメソッドと入力パラメータは以下になります。

dtm_model.print_topic(topic, time, top_terms)
  • topic
    • トピックのID(トピック数10の場合0~9)
  • time
    • タイムスライスで設定した「期間」の順番
  • top_terms
    • 出力する単語の数

このメソッドによる出力結果を用いて、トピックをワードクラウドで可視化しました。
そして、比較的大きく表された単語を基にラベルを付与しました。

最初の週(time=0)のトピックのワードクラウドは以下になります。

f:id:ttakagi1021:20211216184956p:plain
2020年3月 第1週のワードクラウド

f:id:ttakagi1021:20211216185035p:plain
2021年3月第1週のワードクラウド

3. トピックごとの問い合わせ件数集計

dtm_modelでは、問い合わせごとにトピックの出現確率も出力することができます。
そのメソッドと入力パラメータは以下になります。

dtm_model.doc_topics(doc_number)
  • doc_number
    • コーパスや辞書を作成する際に入力した文書の順番

問い合わせごとに最も出現確率の高いトピックをその問い合わせにおけるトピックとし、トピックごとの問い合わせ件数を集計しました。
以下の表は件数の多い順にトピックを並べた結果になります。

2020年3月〜4月

トピックNo ラベル
2 年度更新の各ステップ作業
10 生徒情報の登録
1 生徒や先生の情報登録, ExcelファイルのUPL/DL
6 IDとPW, ログイン, 招待コード, 無償提供
4 管理責任者, 管理者の権限, 生徒カルテ
3 校内グループの設定や配信, 授業の登録
7 学習動画, 学習記録, ダミー生徒
9 継続, 利用停止, 保護者
5 識別番号, 留年や進学した生徒の登録, 人数確定
8 模試データ連携

2021年3月〜4月

トピックNo ラベル
2 生徒情報の登録, ExcelファイルのUPL/DL
3 年度更新
5 年度更新
4 IDとPW, ログイン, 招待コード
8 模試データ連携
7 利用停止, 学習動画パック
6 校内グループ, アンケート
1 管理責任者, 学習動画, 学習記録, 閲覧権限
10 授業登録, 先生登録, 帳票登録
9 人数確定

4. トピック間類似度の計算

手順2で使用したdtm_model.print_topicを用いて、トピックに含まれる単語とその出現確率を50個出力し、2020年と2021年のトピック間の類似度をコサイン類似度で計算しました。

下図はトピック間の類似度をヒートマップによって可視化した結果です。
行が2020年、列が2021年のトピックを示しており、それぞれの類似度の値と値が大きいほど色が濃く表示されています。

例えば、2021年のtopic9は同年の他トピックに比べ2020年のトピックとの類似度がすべて0.5未満と低くなっています。
このことから、topic9は2021年特有なトピックだということが分かります。

f:id:ttakagi1021:20211216190648p:plain
2020年(行)と2021年(列)のトピック間類似度のヒートマップ

5. ダッシュボードによる可視化

手順1〜4の結果をTableauのダッシュボードで可視化しました。

下図は2021年のダッシュボードです。
このダッシュボードで分かることは以下になります。

  • 各年度のトピックを表す単語やその出現確率(図中①)
  • トピックごとの単語の週次変化(図中②)
  • 各トピックに該当する問い合わせ内容(図中③)
  • トピックごとの問い合わせ件数(図中④)
  • トピック間の類似度(手順4のヒートマップを別ページで作成)

f:id:ttakagi1021:20211216201758p:plain
2021年3月〜4月のトピック分析ダッシュボード

内容変化の考察

以上の分析結果を基に、2020年と2021年の内容の変化を考察しました。
ページの都合上詳細は省きますが、いくつか興味深い結果を以下で紹介します。

「年度更新」における各ステップの問い合わせの減少

手順3の問い合わせ件数の多いトピックを見ると、両年ともに次年度のユーザー情報を更新・登録する「年度更新」や「情報登録」についてのトピックが上位3位までに入っています。

2020年はこれらのトピックで「ステップ」や「STEP」という単語を含む問い合わせが多かったのですが、2021年ではそれらの単語を含む問い合わせが減少していました。

この理由としては、2020年から年度更新の手順をステップごとに説明したガイドが作成されたため、ステップごとに問い合わせをする人が増えたと考えられます。

そして、2021年はこれらのガイドを基にした作業を初めて行う先生の数が減少した(作業を理解している先生が増加した)ため、これらの単語を含む問い合わせが減少したと考えられます。

下図は2020年3月〜4月の「年度更新」に関するトピックの推移です。

f:id:ttakagi1021:20211216201916p:plain
2020年の「年度更新」に関するトピックの推移

「利用生徒人数の登録確定」についての問い合わせの増加

手順4で述べたように、2021年のtopic9は同年の他トピックに比べ2020年のトピックとの類似度が低くなっており、2021年特有なトピックとなっています。

このトピックは利用生徒人数の登録確定に関するもので、その作業方法や締切についての問い合わせが多かったです。

この理由としては、2021年は4月に入ってからこれらの確定作業を完了するよう各学校への連絡が増えていました。
その結果、これらの確定作業に関連する問い合わせが増えたと考えられます。

下図は2021年3月〜4月の利用生徒人数の登録確定に関するトピックの推移になります。
4月以降でこのトピックに関連する単語の出現確率が上昇傾向になっているのが分かります。

f:id:ttakagi1021:20211216202022p:plain
2021年の「利用生徒人数の登録確定」に関するトピックの推移

最後に

今回の分析結果から、現状のClassiの設定・登録に関する課題や施策の効果などを考察することができました。
この結果をチームを超えて見ることによって、今年の施策はどうだったのか、今後どういう施策を打っていくべきかを考えることができます。

例えば、カスタマーサポートチームでは今回の分析で、生徒人数の登録確定や年度更新において各設定の期日が分かりづらいことや、年度更新のSTEP3の問い合わせが多いことが明らかになったため、これらの問い合わせを10%減らすことを目標に改善活動を進めています。

また、PMM(プロダクトマーケティングマネージャー)チームでは顧客課題をプロダクトへ反映するために、今回の分析手順をユーザーから寄せられる機能改善についての要望に応用し、その内容の深堀りや要望を実現するための開発の優先順位付けを検討しています。

このように、今回の取り組みが各チームで進められている施策に少しでも寄与できていることは、データAI部としても嬉しい限りです。
分析結果を毎回真摯に聞いて下さりご意見下さる各チームの皆様にはとても感謝しています。

現在、10月までの問い合わせ内容の分析が完了しており、今後は来年の2月までの問い合わせ内容の分析を実施し、年間の問い合わせの傾向を整理していく予定です。

さらに、来年度以降の問い合わせ内容の継続的な分析や、それらの結果を各チームへ素早く還元できる仕組みを構築していきたいと考えています。

明日のClassi developers Advent Calendar 2021の担当は横田さんです。
よろしくお願いします。

*1:Lyric Jumper: https://lyric-jumper.petitlyrics.com

*2:『逆転オセロニア 』における、機械学習モデルを用いたデッキのアーキタイプ抽出とゲーム運用への活用: https://www.slideshare.net/RyoAdachi/deck-archetype-extraction-cedec2019

*3:高木輝彦,高木正則,勅使河原可海,田中健次: e テスティングにおけるLDAを用いた項目間類似度の算出, 情報処理学会論文誌, Vol.55, No.1, pp.91 - 104, 2014.

*4:Deerwester, S., Dumais, S. T., Furnas, G. W., Landauer, T. K. and Harshman, R.: Indexing by Latent Semantic Analysis, Vol. 41, No. 6, pp. 391–407 (1990).

*5:Hofmann, T.: Probabilistic latent semantic indexing, Proceedings of the 22nd annual international ACM SIGIR conference on Research and development in information retrieval, SIGIR ’99, New York, NY, USA, pp. 50–57 (1999).

*6:Blei, D. M., Ng, A. Y. and Jordan, M. I.: Latent dirichlet allocation, J. Mach. Learn. Res., Vol. 3, pp. 993–1022 (2003).

*7:Teh, Y. W., Jordan, M. I., Beal, M. J. and Blei, D. M.: Hierarchical Dirichlet Processes, Journal of the American Statistical Association, Vol. 101, pp. 1566–1581(2006).

*8:Blei D. M. and Lafferty J. D.: Dynamic topic models, Proceedings of the 23rd international conference on Machine learning, pp. 113-120 (2006).

*9:Yan, X., Guo, J., Lan, Y. and Cheng, X.: A biterm topic model for short texts, Proceedings of the 22nd international conference on World Wide Web, pp. 1445–1456 (2013).

*10:中川裕志,湯本紘彰,森 辰則:出現頻度と連接頻度に基づく専門用語抽出,自然言語処理,Vol. 10, No. 1, pp. 27–45 (2003).

UIKitでDesign Systemを実装する

この記事はClassi developers Advent Calendar 2021 の 14日目の記事です。

はじめまして。小中事業開発部でモバイルアプリエンジニアをしています拜郷です。
今回は新規開発中サービスのiOSアプリでDesign System1を実装するにあたって考えたことを書いていきます。

Design System導入の背景

小中事業開発部では現在新規サービス tetoru(テトル) のリリースに向けてチーム開発を行なっています。
開発を進めていく中でUXDチームからDesign System導入の検討がされました。
導入の背景としては開発メンバーが増員するタイミングだったのと、デザインツールの移行が決まったのが大きな要因としてありました。
Design Systemはプロダクト、チームそれぞれの視点で以下の目的を実現できると考えています。

  • プロダクトを通してユーザに一貫性のある体験を提供できる
  • チーム内でデザイン原則を共通認識として持つことができる
  • メンテナビリティとスケーラビリティの向上

iOSアプリ開発の背景

iOSアプリは開発初期の段階からUIKitおよびInterface Builder(以下IB)を扱うStoryboard, xibを使用して開発を行なっていました。
Design System導入のためにIBでのレイアウトをやめたりSwiftUIに変更するのは諸々の事情により現実的ではなかったため、前提として開発方針は変えずDesign Systemの実装検討を進めることになりました。
ただしiOSアプリ開発においてUIKitおよびIBはDesign Systemの実装(主にUIコンポーネントの再利用)に適しているとは言えず少々困難です。
以下ではDesign SystemをUIKitで実装するにあたって考えたことを具体例を交えて紹介していきます。
(※今回紹介するソースコードはすべて本記事用のサンプルコードです。)

デザイントークンの実装

まずDesign Systemにおける最小単位であるデザイントークンの実装を考えます。 デザイントークンにはカラー、余白、行間、Elevation(高さ)、タイポグラフィ、シャドウ、アニメーションなど複数のコンポーネントにまたいで使用される情報を定義します。

カラー

カラーの定義はAsset Catalogを使用します。これはXcode9およびiOS11から使える標準機能なので現時点では特に悩まず使えます。
定義したカラーをコード内で使用する場合、UIColorクラスの初期値にAsset Catalogで定義した文字列を指定します。
ここでSwiftGenR.Swiftを使用することでタイプセーフにリソースを扱えるようになり、タイプミスなどで実行時エラーが発生しなくなる等のメリットがあります。
またAsset CatalogのColorはライト/ダークの各モードをそれぞれ定義することができるため、デザイントークンのカラー定義を双方で検討しておくことでダークモード対応が容易に行えると思います。

// 通常コードからカラーを取得する
UIColor(named: "primary")
// SwiftGenを使用する
Asset.Color.primary.color
// R.Swiftを使用する
R.color.primary()

タイポグラフィ

タイポグラフィの定義はUIFontをextensionして使用することにしました。
SystemFontを使用する場合は単純にトークン名に紐づいたサイズとウェイトを指定して定義します。
カスタムフォントを使用する場合はサイズ、ウェイトに加えフォントファミリーを指定します。ここでもSwiftGenやR.Swiftを使用することでタイプセーフにリソースを扱えます。

public extension UIFont {
    static let title: UIFont = .systemFont(ofSize: 44.0, weight: .bold)
    static let body: UIFont = .systemFont(ofSize: 14.0, weight: .bold)
    static let caption: UIFont = .italicSystemFont(ofSize: 11.0)
    static let button: UIFont = .systemFont(ofSize: 14.0, weight: .regular)
    static let swiftGenSample: UIFont = FontFamily.mplus1.regular.font(size: 14.0)  // SwiftGenでカスタムフォントを指定する
    static let rswiftSample: UIFont = R.font.mplus1pRegular(size: 14.0)!            // R.Swiftでカスタムフォントを指定する
    ...
}

レイアウト要素

余白、角丸、高さなどレイアウト要素の数値を扱うデザイントークンはCGFloatをextensionして定義するようにしました。

public extension CGFloat {
    struct spacing {
        static let xxx_small: CGFloat = 2
        static let xx_small: CGFloat = 4
        static let x_small: CGFloat = 8
        ...
    }

    struct cornerRadius {
        static let small: CGFloat = 2.0
        static let medium: CGFloat = 4.0
        static let large: CGFloat = 8.0
        ...
    }
}

ただしIBからここで定義したレイアウト要素の情報を直接参照できないという課題があります。
この課題に対する1つの解決策として、IB上で設定したNSLayoutConstraintをIBOutletを使ってコードと紐づけるという方法があります。
たとえばある画面をレイアウトする際にSafeAreaとコンポーネントの余白に定義したデザイントークンの数値を使いたい場面があるとします。
この場合IB上では仮の値を設定したNSLayoutConstraintをIBOutletに紐づけます。
あとは紐づけたNSLayoutConstraintにデザイントークンとして定義した値を設定することができます。

f:id:khaigo:20211213101536p:plain
IB上で設定したNSLayoutConstraintをIBOutletでコードと接続する

@IBOutlet weak var btnTrailingConstraints: NSLayoutConstraint! {
    didSet {
        btnTrailingConstraints.constant = <デザイントークンとして定義した値>
    }
}

しかしこの方法はデメリットも存在します。
特に下記のデメリットはIBを使用するメリットを打ち消すので、今回こちらの方法は採用しませんでした。

  • すべての制約をIBOutletに紐づけてコード上で管理するとコード量が肥大化する
  • 動的に制約を書き換えることになるのでStoryboard, xib上のレイアウトと実際に表示されるレイアウトに差異が出る

UIコンポーネントの実装

UIコンポーネントはボタンやフォームなどデザインに使用されるUIパーツを定義します。基本的にはここで定義したコンポーネントの組み合わせで各画面のレイアウトが完成します。
実装方針としてはIBDesignableおよびIBInspectableを使用し、UIKitの各パーツをラップしたクラスを定義しそれをIB上でカスタムクラスとして設定することにしました。
カスタムクラスは定義した各デザイントークンを組み合わせることで共通化を図ります。

またUIコンポーネントを定義する上で、コンポーネントを共通化する単位についてデザイナーとしっかり認識を合わせることが重要になります。
ここでエンジニア、デザイナー間で認識齟齬が起きると以下のような問題が発生するかと思います。

  • 持つべき機能が異なるコンポーネントを共通化してしまう
  • 同じ目的のコンポーネントを別コンポーネントとして切り分けてしまう

いずれもUIコンポーネントが二重管理になったり、想定外の画面でハレーションが起きるなどメンテナビリティ、スケーラビリティを低下させる要因になります。

UILabel

基本方針の通り実装します。カスタムクラスにIBDesignableを付与することでIBから適用したUIが確認できます。
ここでのポイントはUILabelを構成する要素はすべてデザイントークンで定義したものを使用している点です。

@IBDesignable
class TitleLabel: UILabel {
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupAttributes()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupAttributes()
    }
    
    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        setupAttributes()
    }
    
    private func setupAttributes() {
        // デザイントークンで定義した値を設定する
        font = .title
        textColor = R.color.systemGray_800()
    }
}

@IBDesignable
class BodyLabel: UILabel {
    ...
}

@IBDesignable
class CaptionLabel: UILabel {
    ...
}

定義したUILabelのカスタムクラスを設定することでIB上で反映されたスタイルを確認する事ができます(以下サンプル)。

f:id:khaigo:20211213101832p:plain
定義したUILabelのカスタムクラスを設定する

UIButton

こちらも基本方針通りです。
UIButtonの状態に応じてlayoutSubviewsでスタイルを切り替えるようにしています。
その他UIViewを継承するいずれのUIクラス(UITextView, UITextField etc)もこの方針でカスタムクラスを定義してUIコンポーネントを作成するようにします。

@IBDesignable
class FillButton: UIButton {
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupAttributes()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupAttributes()
    }
    
    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        setupAttributes()
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        switch state {
        case .normal:
            backgroundColor = R.color.brandAccessible()
            titleLabel?.textColor = R.color.systemGray_000()
        case .highlighted:
            backgroundColor = R.color.brandAccessibleActive()
            titleLabel?.textColor = R.color.systemGray_000()
        case .disabled:
            backgroundColor = R.color.buttonDisabled()
            titleLabel?.textColor = R.color.disabled()
        default:
            break
        }
    }
    
    private func setupAttributes() {
        layer.cornerRadius = .cornerRadius.medium
        backgroundColor = R.color.brandAccessible()
        titleLabel?.font = .button
        titleLabel?.textColor = R.color.systemGray_000()
    }
}

@IBDesignable
class CancelButton: UIButton {
    ...
}

@IBDesignable
class TextButton: UIButton {
    ...
}

定義したUIButtonのカスタムクラスを設定することでIB上で反映されたスタイルを確認する事ができます(以下サンプル)。

f:id:khaigo:20211213102016p:plain
定義したUIButtonのカスタムクラスを設定する

まとめ

最後までお読みいただきありがとうございます。
まだまだ諸々の事情によりSwiftUIではなくUIKitを使用するプロジェクトは多々あるかと思います。
実装に関しては基礎的な内容だったかも知れませんがDesign Systemを実装する際の考え方の参考になると幸いです。
私自身もDesign Systemとその実装についてまだまだ模索中ではありますが今後もUnlearn & Learnでやっていきたいです!

Classi developers Advent Calendar 2021 の 15日目はおかじさんです。


  1. Design Systemとは諸説ありますが、大まかに言うとプロダクトのデザインにおける一貫性を効率的に保つための仕組みのことです。
    Design Systemを構成する要素としては、概念・原則をまとめたドキュメント、スタイルガイド、UIコンポーネントライブラリ、またそれらを管理・運用するためのルールやツールなどがあげられます。

IAM Policy Simulator で「必要な権限足りてる?」を確かめる

この記事は Classi developers Advent Calendar 2021 の13日目の記事です。

こんにちは。開発本部プロダクト開発部学習チームでエンジニアをしています、藤田です。 本記事では AWS の IAM の Policy の定義から、アクセス可能なリソース範囲・許可されるアクション等を事前に検証できる IAM Policy Simulator を紹介します。

続きを読む

EC2からECSへ移行する道のり

開発本部の onigra です。今回の記事は、Classiのアプリケーション実行環境をAmazon EC2からECSに移行しているお話をします。

この記事では「Ruby on RailsのWebアプリケーションをECSに移行する上での技術的なトピック」ではなく、「なぜClassiはEC2からECSに移行する必要があるのか」「どのように移行を進めているのか」についてをお話します。

なお、この記事は Classi developers Advent Calendar 2021 11日目の記事です。昨日は データプラットフォームチームの滑川さんによる Cloud Composer 2へのupgradeでどハマりした話 でした。

なぜEC2からECSに移行する必要があったのか

私が入社した当初、Classiは前述の通りAmazon EC2で稼働していました。それ自体に問題は無いのですが、サーバのプロビジョニングとデプロイが遅く、不安定という課題を抱えていました。

また、普段プロダクトを開発・運用しているチームの中にデプロイ、インフラの知識や、既存の仕組みを把握しているメンバーが少なく、プロビジョニングやデプロイに何か問題があると、私のような組織横断的な技術課題に取り組むメンバーが対応にあたることが多々ありました。

近年、書籍 『LeanとDevOpsの科学』 でも語られているように、変更のリードタイムとデプロイの頻度は組織のパフォーマンスに大きく関わる要素であり、今後価値提供のスピードを上げていくにあたって大きな障害となるため、なんとか全社的に解決したい課題だと考えていました。

そんな中、 2020年の春に不正アクセスが発生し、その後サービスの高負荷によるアクセス障害が続く状態 になりました。

日々対応に追われる中、次から次へとサーバのチューニングやアプリケーションの修正をデプロイしなければならない状況で、デプロイとプロビジョニングの仕組みが大きなボトルネックになっていることをこの時に改めて痛感します。

また、今後Classiがセキュリティ面でもパフォーマンス面でもお客様からの信頼を回復しなければならない中で、今のままの仕組みではとても改善のスピードを上げることはできません。

この時、現状のEC2へのプロビジョニング、デプロイの仕組みを全て捨て、ECSに移行して1から作り直そうという決断をしました。

移行の戦略

最初の移行の成否が今後を左右する

この時点で現場は日々の対応で疲弊していました。そんな状況を打破し、サービスの改善にポジティブな勢いを生むためには、移行する1つめのリポジトリの成果に大きく左右されると考え、確実に成功させる必要がありました。

そのため、最初の移行対象はコンテナ化の難易度が比較的低いWeb APIの機能のみを提供しているリポジトリを選択することにしました。

運用ができるメンバーを増やしつつ、移行する

私はコンテナを普段から利用し、ECSを触った経験があったので、移行する上で知識の問題は無いのですが、コンテナの運用に慣れていないメンバーへレクチャーをしきれるのかという懸念がありました。

また、Classiには多くのリポジトリがあり、それらを1つ1つ移行していたらかなりの期間がかかってしまうことは容易に想像できます。そのため、移行作業と移行のレクチャーができる人員をスケールさせる戦略を練る必要がありました。

そのため、移行作業は以下のようにメンバーを巻き込みつつ、最終的に自分以外のメンバーがレクチャーを行えるような状態を目指して進めることにしました。

  1. 自分で検証環境の移行を行い、必要な作業を把握する
  2. 本番環境の移行に取り掛かる際に一緒に作業するメンバーをアサインし、ペア作業でレクチャーしながら移行を完了させる
  3. 次のリポジトリに取り掛かる際に新たにメンバーをアサインし、2のメンバーにレクチャーしてもらう
  4. 以降、自分はサポート役に回る

また、本番リリース直前は必ず一緒に「あと何をすればリリースできるのか」を確認する機会を設けました。 いわゆるリリーススプリントに臨むための準備です。以下のような内容を確認、レクチャーし、リリースマネジメントを行いました。

  • Datadogの使い方
  • Sentryの使い方
  • 依存するアプリケーションの考慮
  • 本番ネットワーク疎通確認と方法の確認
  • リリースの手段と、ロールバックの仕方
  • 関係各所への連絡と期待値調整
  • EC2環境と、不要になるAWSのリソースを削除する手順

これらを移行作業の中でレクチャーすることによって、今後リポジトリ担当者が自立してサービスを運用できる状態になることを目指しました。移行の完了は終わりではありません、始まりなのです。

結果

2021年12月現在、13のリポジトリが移行完了しています。

何が移行を勢いづけたのか

フォロワーシップを強く発揮するメンバーの存在

同じ課題感を感じていたメンバーがフォロワーシップを発揮し、2つめのリポジトリの移行作業を早々に完了させ、以降に続くメンバーの勧誘とレクチャーを積極的に行ってくれました。

そのおかげで予定よりも早く自分がサポーターの役割に移行でき、アプリケーション固有の問題や例外的な対応をメインに行うことによって、移行プロセスのボトルネックの解消にフォーカスすることができました。

成長機会を貪欲に掴みにきた新卒、若手メンバーの存在

移行するメンバーを募集した際、新卒や若手が積極的に手をあげて参加してくれました。私自身、キャリアの中でもトップレベルに困難な状況と感じていたのに、それを成長機会と捉え、できることをしようとする姿勢には強く勇気づけられました。

会社の危機的な状況に対し、若いメンバーからサービス改善に繋がるポジティブなムーブメントを起こせたことは、非常に大きな意義があったと思っています。

移行に参加したメンバーの1人である小川さんがECS化について書いた記事も是非ご覧ください。

新卒1年目でECS化に取り組んだことを振り返る

移行に関わるメンバー同志のコミュニティのようなつながり

私はチャットへのレスポンスが早い方なのですが、移行作業を行うメンバーが集まるチャネルではよりそれを心がけました。未知の作業を行うことに不安を感じているメンバーに対し、何か書き込めばレスポンスが返ってくる安心感を与えたかったからです。結果的にその行動はチャネルが盛り上がることに繋がり、コミュニティのようになりました。

移行セレモニー

社内にポジティブな雰囲気を起こすことと、移行しきったメンバーを称えて成功体験にしてもらうために、移行が完了したら担当したメンバーにオープンチャンネルで移行完了宣言してもらい、スタンプを可能な限りつけて盛大に祝うというセレモニーを必ず行っていました。

f:id:onigra:20211208190426j:plain

その後

運用の知識を身につけたメンバーを触媒に、社内で徐々にサービス運用を改善する動きが浸透していきました。

運用改善を行うWorking Groupの定期開催化

まず、株式会社はてな様で行っている Performance Working Group を自主的に行うチームが現れました。その活動が他のチームに広がり、チームで個別に行うだけではなく、インフラチームが主催で行う組織横断的なPerformance Working Groupも定期開催されるようになりました。

また、定期的にSentryの整理やエラー内容の調査する取り組みも各チームで行われています。アプリケーションやエラー監視ツールはインシデント以前から導入されていたものの、これまではエラーが多すぎたり、使い方がわからないメンバーも多く、放置されがちだった状況から大きく改善されました。

これは、lacolacoさんがSentryについての継続的な活用支援を続けてきてくれたことも大きな後押しであったと感じています。

Sentryを活用するためにやっていること

システムアラート発生時のメンバーのレスポンスが向上した

インシデント以前はシステムアラートの発生に対して、特定のメンバーしか反応しない・できない状態で、反応したメンバーが担当しているチームに「これ大丈夫ですか?」と伝えに行くことも多かったのですが、徐々に反応するメンバーが増えてきて、今では担当チームのメンバーがシステムアラート発生から真っ先に「確認します」と反応することがほとんどになりました。

これにはインフラチームが主体で行っていた、サービスのピークタイムの監視を当番制で行う取り組みが定常化し、インフラチーム以外のメンバーもその当番に参加するようになり、システムアラートへの対応経験を積んだメンバーが徐々に増えていったことも後押しになりました。

今となってはピークタイムの監視当番は、システムアラート対応のオンボーディングの役割も兼ねるようになりました。

インフラに興味を持つメンバーが増えた

移行作業をきっかけにインフラに興味を持ち、自主的にAWSの資格を受験して合格したメンバーも数名います。

AWS 認定 ソリューションアーキテクト - アソシエイト(SAA-C02) 試験に合格したので振り返る

受験費用は会社でサポートしているのですが、最初に受験したメンバーは会社で精算できるということを知らず、たまたまSlackで「この間の試験受かってよかった〜」という発言を見かけて「会社で精算できるから申請してね」と声をかけたこともありました。

また、理解の必要はあるが自分で構築することは少ないAWSのネットワーク(VPC、Subnetなど)について、移行に参加したメンバーが勉強会を開催したいと相談され、一緒に内容を考えてハンズオンを実施したりもしました。

その他

監視についての記事を開発者ブログに執筆してくれるメンバーもいました。

AWS ECS監視のオオカミ少年化を防ぐために考えたちょっとしたこと Amazon EventBridge(CloudWatch Events)で動かしているバッチをDatadogで監視する仕組みを構築した話

現状維持を良しとせず、試行錯誤しながらアウトプットする姿勢はとても頼もしく感じます。

反省点

ここまで良かった点ばかり書いていますが、当然省みることもあります。

「移行に関わりたい」というメンバーが想定以上に多く、移行作業が勢いよく進んでしまい、私がコントロール仕切れないこともありました。また、私が移行作業しつつアクセス障害への対応も行なっていたため、当然作業の中でボトルネックになってしまうことも発生し、十分にサポート仕切れないまま進んでしまい、不安を感じさせてしまったメンバーもいました。私がボトルネックになってしまうことは、事前に予測できていたことでした。

移行のふりかえりを行なった際に、「表面的にうまくいってると評価されているECS移行のイメージと参加当初ギャップがあった」というフィードバックをもらいました。これは当時、十分なサポート体制を組めなかった状態を的確に指摘していると感じています。

最後に

結果、Classiでは現在ほとんどのサービスをECSで運用し、デプロイの安定化と頻度の向上に成功しました。また、移行作業を通して若手メンバーを中心に、全社的な運用力の底上げに繋げられたと感じています。最悪の状況から前を向けるようになったのは、間違いなくこの時に成長してくれたメンバーのおかげです。

一方で繰り返しになりますが、移行の完了は終わりではありません、始まりなのです。移行は価値提供のスピードを上げていくにあたっての最低条件でしかありません。また、「ほぼ」と書いた通り移行完了していないリポジトリは残っています。

引き続き、より良いサービスを運用していくために精進していきます。

Cloud Composer 2へのupgradeでどハマりした話

この記事は Classi developers Advent Calendar 2021 の10日目の記事です。

こんにちは、データプラットフォームチームの滑川(@tomoyanamekawa)です。
Google CloudのCloud Composerのversion2(Cloud Composer 2)がpreview公開され、Terraformでも10月末から作成可能になりました*1

「Cloud Composer 2ならworker数をautoscalingしてくれるらしい。そんなに設定変わらないだろうからサクッと移行しよう。」 くらいの軽い気持ちでCloud Composer 1からupgradeをしましたが、てこずってだいぶ時間を溶かしてしまいました。

そのハマったポイント4つとCloud Composer 2へupgrade完了した上での所感をまとめた記事です。

※追記:
2021年12月16日にgenerally available (GA)になりました。 https://cloud.google.com/composer/docs/release-notes#December_16_2021

*1:v3.90.0からnode_configを含めたCloud Composer 2をTerraformで作成できるようになった

composer: removed config.node_config.zone requirement on google_composer_environment (#10353) https://github.com/hashicorp/terraform-provider-google/releases/tag/v3.90.0

続きを読む

Amazon EventBridge(CloudWatch Events)で動かしているバッチをDatadogで監視する仕組みを構築した話

開発本部 認証連携チームでエンジニアをしている、id:ruru8net です。

これはClassi developers Advent Calendar 2021の9日目の記事です。
昨日の記事はこちらです。 Hardening 2021 Active Fault 参加レポート - 桐生あんずです

以前のClassi Advent Calender 2019では新卒が入社半年で社内サービスをリリースしてエンジニア楽しいってなったお話を書かせていただきましたが、あれから2年の間に業務の中で様々な経験をし、さらに知識やスキルを身につけていくことができました。

今日はその中でも自分が担当しているサービスの、バッチ監視の仕組みを考えたので紹介させてください。

背景

担当チームでは毎日深夜2時にDBからデータを削除するバッチを動かしています。
他にも社内では様々なバッチが動いていますが、これらを監視する仕組みは社内で確立されていませんでした。
そのためサービス稼働に影響の少ないバッチは実行中に問題があったり、そもそも実行されていなかったりしても検知されず、見過ごされてしまうことが多かったです。
弊社ではサービスの監視にDatadogを使用しているため、この監視体制にそのままバッチの監視を組み込むことでバッチの監視ができていない状態を是正したいと考えました。

前提

バッチファイル

Ruby on Railsを使い、rake taskとして実行させています。

バッチの仕組み

Amazon EventBridgeにてECS Fargateのタスクを起動させ、実行しています。これは既にdatadog-agentコンテナが動いている前提です。datadog-agentコンテナの設定方法は以下のURLを参考にしました。

https://docs.datadoghq.com/ja/integrations/ecs_fargate

f:id:ruru8net:20211208124211p:plain
バッチの構成図

使用する監視、通知ツール

  • Datadog
  • Slack

監視したいこと

バッチ実行において監視したいことは以下です。

  1. 定期的な実行の成功と失敗

    • バッチ実行中に例外が発生した場合の検知
    • バッチ実行用のタスクの起動自体がされなかった場合の検知
    • 例外を発生せずに何らかの理由でバッチ実行のコンテナが終了してしまった場合の検知
  2. 実行時間の異常

今回は定期的な実行の成功と失敗をメインとして、

  • バッチ実行中に例外が発生した場合の検知

    • →発生した例外をDatadog Eventとしてエラーを送信。DatadogのMonitorにてエラー通知を監視するMonitorを作成しエラーが送られてきた場合はslackにアラートを送信する。
  • バッチの起動自体がされなかった場合の検知

    • →バッチ実行の成功をDatadog Eventとして送信。DatadogのMonitorにて成功通知を監視するMonitorを作成し、成功通知が送られてこなかった場合はSlackにアラートを送信する。

という監視の仕組みを作っていきます。

f:id:ruru8net:20211208124333p:plain
バッチ実行を監視する仕組み

手順

1. dogstatsd-rubyを使ってバッチのスクリプトファイルにDatadogへEventを送信するよう書く

DatadogにEventを送信する方法は4つあります。

docs.datadoghq.com

  • Custom Agent Check
  • DogStatsD
  • Email
  • Datadog API

今回のようにsidecarコンテナとしてdatadog-agentを起動させているのであればDogStatsDを使ってEventを送るのがやりやすいと思います。

今回はRubyで書いているので基本的にはDatadogのドキュメントに書いてあるExampleと、使用するgemであるdogstatsd-rubyのドキュメントを参考にコードを書きました。 docs.datadoghq.com github.com

▽作成したバッチのスクリプトファイル

require 'datadog/statsd'

task batch: :environment do
  begin
    begin
      # バッチの実行時間を計測
      execution_time = Benchmark.measure do
        ###
        # 実行処理内容は省略
        ###
      end

      statsd = Datadog::Statsd.new(logger: logger, single_thread: true, buffer_max_pool_size: 1)
      begin
        # バッチの実行が完了したら成功Eventを送る
        statsd.event(
          'データを削除するバッチ', # Eventのタイトル
          "バッチ実行時間 #{execution_time.real}s", # 好きな内容をメッセージとして送れる
          alert_type: 'success',
          tags: ['env: development', 'service:rails-app'] # タグを指定
        )
      rescue => e
        logger.error e
      ensure
        statsd.close()
      end
    rescue => e
      begin
        # バッチ実行中に問題が発生した場合はエラーEventを送る
        statsd = Datadog::Statsd.new(logger: logger, single_thread: true, buffer_max_pool_size: 1)
        statsd.event(
          'データを削除するバッチ',
          "#{e.class}:#{e.message}",
          alert_type: 'error',
          tags: ['env: development', 'service:rails-app']
        )
        logger.info 'Datadogへのエラー通知送信完了'
      rescue => e
        logger.error e
      ensure
        statsd.close()
      end
    end
  end
end

def logger
  Rails.logger
end

解説

statsd = Datadog::Statsd.new(logger: logger, single_thread: true, buffer_max_pool_size: 1)

statsdのインスタンスを作成します。
dogstatd-rubyのバージョンや必要に応じてオプションをつけてください。

https://github.com/DataDog/dogstatsd-ruby#migrating-from-v4x-to-v5x https://www.rubydoc.info/github/DataDog/dogstatsd-ruby/Datadog/Statsd

begin
  # バッチの実行が完了したら成功Eventを送る
  statsd.event(
    'データを削除するバッチ', # Eventのタイトル
    "バッチ実行時間 #{execution_time.real}s", # 好きな内容をメッセージとして送れる
    alert_type: 'success',
    tags: ['env: development', 'service:rails-app'] # タグを指定
  )
rescue => e
  logger.error e
ensure
  statsd.close()
end

eventメソッドが取れるパラメータやオプションはこちらに書いてあります。 www.rubydoc.info

またドキュメントに書いてある通り、DogStatsDのクライアントが不要になった時には適切に破棄をするためにstatsd.close()します。

2.バッチを走らせてeventがDatadogに送られているかを確認する

https://app.datadoghq.com/event/stream にてeventの一覧が確認できます。 左上の検索欄に、eventのタイトルやタグで検索ができます。
このときの検索で、eventが一意に絞り込めるようなタイトル、タグをつけるようにしてください。

またメッセージの内容も一緒に出力されます。
ですので、ここに実行時間や、実行完了したときに欲しい情報を出力させておくと確認がしやすいです。

event例

状態
成功時
f:id:ruru8net:20211208124846p:plain
成功のevent
例外発生時
f:id:ruru8net:20211208124740p:plain
エラーのevent

3. Datadog Monitorを作成する

Monitors > + New Monitor > Event を選択します。
するとMonitor作成画面になります。今回は「バッチ実行中に例外が発生した場合の検知」と「バッチの起動自体がされなかった場合の検知」をする2つのMonitorを作成します。

バッチ実行中に例外が発生した場合の検知

エラーeventのみを絞り込むように設定し、alert conditionsをセットします。今回は24時間に一回動くバッチのため、24hoursを選択、また1つでもエラーeventを受け取ったらalertとして発火させたいのでAlert Thresholdを1にしています。

f:id:ruru8net:20211208125012p:plain
エラーeventを受け取った時にalertを発砲するDatadog Monitor 作成画面

バッチの起動自体がされなかった場合の検知

成功eventのみを絞り込むように設定します。
またeventをカウントする期間を24hoursにしてしまうと、前回のeventからきっかり24時間以内にeventが来ないとalertとなってしまうので、余裕を持たせるために25hoursにしておきます。
対象期間1つも成功eventがない場合はバッチの起動がされなかったとみなしalertを送るように、Alert Thresholdを1にします。

f:id:ruru8net:20211208125343p:plain
成功通知がない場合にalertを発砲するDatadog Monitor 作成画面

③で通知させたい先のslackチャンネル(slack-{チャンネル名}となっているもの)を選択します。
(DatadogとSlack連携のセットアップはこちら
https://docs.datadoghq.com/ja/integrations/slack/?tab=slackapplicationus)

④ではslackに投稿する際のテンプレートを作成します。
ここでは色々な変数やMarkdownが使えます。

4. Monitorで設定した通りにslackに通知が来ることを確認

Monitor作成時の右下にあるTest Notificationsで確認ができます。

f:id:ruru8net:20211208125518p:plain
Test Notifications

下のようにSlackに通知が送られるようになりました。

バッチ実行中にエラーが発生した場合の通知 バッチの実行確認ができなかった場合の通知
f:id:ruru8net:20211208125610p:plain
f:id:ruru8net:20211208125906p:plain

おわりに

実装について

DogstatsDによるEvent送信はバッチ処理中への埋め込みがしやすくとても使いやすかったです。
またバッチに限らず監視の仕組みを考える時にはまず、「何を監視したいのか」を整理するのがとても大事だなと思います。
今回は実行の監視のみしかできていませんが、今後は実行時間がかかり過ぎていた場合にalertを発報できるような仕組みも監視の項目に入れていきたいです。
(現状はeventのメッセージに対してMonitorを作成する方法が見つからず、別の方法を模索中です。)

監視の仕組み構築について

社内で確立されていなかったバッチの監視に対して、この仕組みを社内展開することができ、他のチームの人たちからも喜んでいただけたので嬉しかったです。
自分のチームだけでなく他のチームにとっても役に立つような仕組みづくりというのを意識して今後も頑張っていきたいです。

明日のClassi developers Advent Calendar 2021の担当はTomoya Namekawaさんです。お楽しみに。

© 2020 Classi Corp.