移行パターン(ストラングラー、抽象化ブランチ、同時実行)を活用してブラックフライデーを乗り切った負荷対策

エンジニアの木村です。この記事は Enigmo Advent Calendar 2021 の 13日目の記事です。 いろいろやってますが、BUYチームという購入UXに関わる機能開発を担当するチームのマネージャーもやっています。11月末に行われるブラックフライデーサイバーマンデーといった大型キャンペーンに備えた開発もそのチームで担当したのですが、今日はそれに備えて行った負荷対策の1つの取り組みについてお話しします。

悔しい思いをした去年のブラックフライデー

これは昨年のブラックフライデー開始直後のBUYMAオフィシャルアカウントのツイートになります。。悲しいですね。下のリンクから当時のBUYMAの状況を表すタイムラインが見れますが、どういう状況だったか推察いただけると思います。

バイマ OR buyma until:2020-11-27_00:50:00_JST -filter:links -filter:replies - Twitter Search

ユーザーのログイン状態管理が重い

原因の1つは、ユーザーのログイン状態の管理をサービスのメインDBで行っているところでした。スケールが難しいRDBです。最近はアクセス集中時にユーザーのログイン済み比率も増え、そのユーザーのアクセスでは毎リクエストそのDBのテーブルへSELECTが走るのでメインDBが高負荷になり、メインDBなのであらゆる機能へ影響するためサイトとしては繋がりにくくなるという状況でした。もちろん、以前から問題視されていた仕組みではありましたが、ログイン状態の管理という重要な機能でもあり、原作者もおらず、別の仕組みへの移行の難易度が高くずっと放置してしまっていました。

したがって、DBのリソースをログイン管理のために温存する必要があるため、動的コンテンツ(HTMLやAPI)配信にもCDNを入れてキャッシュしたり、DB負荷が高い機能はアクセス集中が予測される時間帯には停止するようにスケジュールしたりなどいろんな手を尽くしていま。しかし、それでもブラックフライデーだけは耐えることができませんでした。

セッションの保存をRDBからredisへ

そこで、ログイン状態の保存先をRDBから、より読み取りレイテンシーが小さく、読み取りの集中にも耐久性のあるRedisへと移行するプロジェクトが始まりました。その移行の際に行った工夫が記事の本題となります。この工夫が、たまたま後日読んだこちらの本で紹介されている移行パターンそのものだったのでこちらの本の用語を使って説明していきます。

利用パターン1:抽象化によるブランチ

現代のソフトウェア開発では1つのコードベースでブランチを切って複数人で並行して行うのは普通ですが、あまりブランチを切ってから長い時間が経つと差分が大きく、レビューやマージが大変になります。そこで、実装を改良しようとしている機能の抽象を作り、既存の実装と並行してその抽象の新しい実装を別に作り、さらにあとで切り替えるという方法が抽象化によるブランチと呼ばれる移行パターンです。大きな変更であっても、寿命の長いブランチを作らずに、他で並行して行われる開発と影響しにくいように開発が可能です。今回の事例で1ステップずつ手順を説明していきます。

今回は抽象というよりはファサードとなるクラスを前に立てて新実装へ切り替えているので、モノリス内でのストラングラーパターンと言っても良いかもしれません。

ステップ0:変更前

変更前のアプリケーションはこのような構成でした。ActiveRecordのモデルを介してDBのテーブルを呼び出すというオーソドックスな作りかと思います。

f:id:enigmo7:20211203164956p:plain

ステップ1:ファサードを作成し、既存のクライアントの向き先を変更

最初に行ったのは、実装の変更を呼び出し元へ影響するのを抑えるため、ログインステータスの操作を抽象化するためにファサードとなるクラスを設けたことです。また、このファサードを利用するように、順次呼び出し元の向け先もこのファサードへ切り替えました。

f:id:enigmo7:20211203164959p:plain

ステップ2:並行して新しい実装を作成

従来の実装を稼働させつつ、新しい実装を横に作成します。実装のソースはデプロイされますが、この段階では事実上、従来の実装だけが動いたままです。

f:id:enigmo7:20211203165002p:plain

ステップ3:ファサード内で新しい実装を使うように切り替え

ファサード内部を変更して、処理の流れを従来の実装から新しい実装へと切り替えます。この切り替えは後述する 同時実行パターンカナリアリリース を使って段階的に行いました。

f:id:enigmo7:20211203165004p:plain

利用パターン2:同時実行

新旧の実装を切り替える時に、それぞれの実装で処理結果が変わらないことを担保する必要があるのですが、通常、デプロイ前にテストを頑張るという手段になると思います。ただ、テスト尽くしても本番環境で起こりうるシナリオは全て網羅することは困難です。特に、今回の変更はユーザーのログイン状態の管理になるので、不具合があると誤ってユーザーをログアウトさせてしまったり、あるいはBANしたユーザーを誤ってログインさせてしまったりなど重大な問題となってしまいます。

そこで、新実装を本番へ出してしまい、旧実装と同時に新実装も呼び出しつつ、旧実装の結果を呼び出し元へは返しておきながら、それぞれの実行結果を比較して新実装の結果が信頼できるか検証するという 同時実行 というパターンを利用しました。こちらの図のとおり、ファサード側でDB、Redisそれぞれの結果を処理が通るたびに比較検証し、差分があればログに出しておくようにしました。また、ファサードの処理のタイミング以外でもバッチで定期的にそれぞれの内容をスキャンし、差分有無の検証も行いました。差分が出れば原因を調査し、原因を潰していくという作業を差分が出なくなるまで続けました。

f:id:enigmo7:20211203225922p:plain

カナリアリリースも利用

最初に同時実行モードに入る前に、気になったのはRedisの負荷でした。ほかの機能で導入実績はありましたし、負荷テストはしていたものの、やはり本番のトラフィックを一気に向けるのは勇気が要りました。そこで、徐々に10%ずつ新実装へトラフィックを向けるようにカナリアリリースを行いました。また、同時実行により十分に検証を行い、新しい実装へと切り替えるタイミングでも、予期しないユーザーへの影響の可能性を考え、そのタイミングでも10%ずつ新実装のみの結果を利用するようにしています。カナリアリリースの仕組みは、cookieベースでトラフィックを振り分ける既存のABテストの仕組みを利用しています。

心穏やかに移行完了

以上の工夫によりあまりドキドキすることなく、心穏やかに移行を終えることができました。数ヶ月ほど時間はかかりましたが、抽象化ブランチの仕組みにより、切ってから長時間経った差分の大きいブランチをマージする必要も無かったですし、同時実行によりバグを十分出し切った上で、しかもカナリアリリースで少しずつリリースできたためです。時間がかかるというデメリットはあったものの、特に品質に気を遣う変更には有用かと思います。

負荷対策の効果

ついに迎えたブラックフライデー当日ですが、サイトのパフォーマンスを落とすことなく乗り切ることができました。こちらは、開始直後11/26の0時台の企画メンバーが集うslackのチャンネルの様子です。例年なら不安定になるはずのサイトがサクサク動いていて盛り上がっていました。

f:id:enigmo7:20211203234506p:plain

もちろん、今回紹介した移行以外にも負荷対策や様々なパフォーマンス改善策を重ねた結果なのですが、それらについてはまた別の機会に紹介しようと思います。

Terraformにまつわる運用tips的なもの

この記事は Enigmo Advent Calendar 2021 の12日目の記事です。

こんにちは。BUYMAの検索やMLOps基盤周りを担当している竹田です。
この一年間はTerraformを業務で利用することが多かったため、普段気を付けていることなどを運用tipsとして紹介したいと思います。

Terraform

Terraformは言わずと知れたInfrastructure as Code (IaC)を実現するためのツールです。
先日v1 🎉 になり、安定してきた印象があります。 f:id:enigmo7:20211207145027p:plain

Terraformに限った話ではありませんが、コード管理されている安心感は何ものにも変えがたいものがあります。 f:id:enigmo7:20211207151117p:plain

本記事はTerraform自体の説明は割愛し、ある程度利用したことのある方を対象としています。
個人の主観を多分に含んでいる、およびGoogleCloudに寄った内容のため他クラウドベンダーとは若干記述の毛色が違うといった可能性があります。あくまで参考程度としていただけると幸いです。
また、 terraform Advent Calender もあるようですので、より多くの情報を得たいという場合はそちらも参考にしていただくと良いかと思います。

定期的なバージョンアップ

Terraformはv1化により安定してきたように思いますが、クラウドベンターのproviderは変化のサイクルが早く、新機能の追加やベータ版のGA化など定期的に追従した方が良いです。

  • 気付かない内に有用な機能がリリースされており、無効の状態で運用を継続している可能性がある
  • 新機能があった場合に気付きを得られる
  • 項目の定義方法が変更になっている場合がある

設定理由を残す

インフラ面のライフサイクルとして定義変更の頻度が少ないこともあり、いざ定義を変更しようとした際に設定理由を忘れていることが多々あります。
「この機能は何々の理由で有効・無効にした」といった内容を何かしら文章として残しておくことをお勧めします。

terraform applyの際にエラーとなってしまう場合がある

主に認証周りや、providerサイドで項目のvalidationがされていないものについて発生することがあります。terraform planした際には正常終了するものの、いざterraform applyするとエラーになるというものです。
すでにコード管理上にcommitしていると二度手間になるため、開発環境やテスト可能な環境でterraform applyまで実施しておくことをお勧めします。

書き方のtips

tfファイルの記法は若干クセがあるため、どの書き方が適切なのか、どう書くとやりたいことを実現できるのか分からない状況に遭遇することが稀にあります。
以下に一部例を挙げてみます。


変数参照

ハッシュ記述value["preemptible"]なのかシンボル的記述value.preemptibleなのか迷います。大抵はどちらでも問題ありません。
ただし、混在すると見にくくなったり、記述箇所によっては意図があるように感じたりする場合があるため、統一感を持たせた方が良いように思います。
↓こんな混在した書き方ができてしまう

  node_config {
    preemptible     = each.value["preemptible"]
    machine_type    = each.value.machine_type
    disk_type       = each.value["disk_type"]
    oauth_scopes    = each.value.oauth_scopes

dynamicブロック

環境毎にリソース内の定義有無を設定できるため非常に便利なdynamicブロックですが、リソースによっては後で削除できないパターンが存在します。
例えば GoogleCloud のノードプールに設定可能なtaint定義などが該当します。taintの説明はこちらを参照ください。
なお、ここで例として挙げているtaintの変更は、変更の際にノードプール自体が作り直されてしまいますのでご注意ください。

taint定義を以下のようにdynamicブロック定義していたとします。

    dynamic "taint" {
      for_each = contains(keys(local.gke_nodepools), "taints") ? local.gke_nodepools.taints : []
      content {
        key    = taint.value.key
        value  = taint.value.value
        effect = taint.value.effect
      }
    }

variablesは以下のようになっており、terraform applyにより実環境にtaintが設定されていたとします。

    locals {
      gke_nodepools = {
        taints = [
          {
            key    = "testkey"
            value  = "testvalue"
            effect = "NO_SCHEDULE"
          }
        ]
      }

あるタイミングでtaint定義が不要となり、削除したいと考えてvariablestaints項目自体を除去してterraform planを実施します。すると

No changes. Infrastructure is up-to-date.

削除差分が出ると思っていたのに差分が一切検出されませんでした 😢

これはdynamicブロック対象のオブジェクトを定義しない場合に、そもそも定義が存在しないものと解釈されて空の内容では更新してくれないためです。
こういった場合は、taint定義を以下のようにします。

    taint = [
      for item in local.gke_nodepools.taints :
      {
        key    = item.key
        value  = item.value
        effect = item.effect
      }
    ]

続いて、variablesを空に設定します。

    locals {
      gke_nodepools = {
        taints = []
      }

このように定義することで、taint定義を空で更新することが可能となります。
以下はterraform plan時の差分結果です。

          ~ taint             = [ # forces replacement
              - {
                  - effect = "NO_SCHEDULE"
                  - key    = "testkey"
                  - value  = "testvalue"
                },
            ]

最後に

システム規模としては小〜中規模のため、規模面で困ったことは今のところありません。
大規模システムでは自動化などを考慮する必要がありそうなので、その辺りの悩みがない分は楽かもしれませんね。
今後はTerraform以外にもIaC関連のツールを利用するかもしれませんが、しっかり追従していきたいなと思います!

明日の記事担当はエンジニアマネージャーの木村さんです。お楽しみに!


株式会社エニグモ すべての求人一覧

hrmos.co

BigQueryとAirtableをGASとZapierで自動連携してNPSアンケートチェックを効率化した話

こんにちは、エニグモカスタマーマーケティング事業本部で出品審査などを担当している杉山です。この記事はEnigmo Advent Calendar 2021の10日目の記事です。

昨年のアドベントカレンダーでは、日頃の担当業務についてWantedlyで書きましたが、今回は開発者ブログにお邪魔しました。

エンジニアではなくビジネスサイドの人間ですが、通常業務の傍らITツールを駆使して、ユーザー対応の現場の自動化や効率化などに取り組んでいます。

どうしても手元で作業が必要で、エンジニアに開発してもらうほどではないんだけど、これが自動になったら楽なのに!とかもっと効率よく日々の業務をこなして価値の高い仕事に取り組みたいよー!と思う瞬間がありませんか?私はあります。もう毎日のようにあります。

もちろん大規模な運用改善やシステムの導入などはエンジニアと一緒に開発したほうが圧倒的にいいという案件も多々あるので見極めが必要ですが、ひとまず現場の人間が(ほぼ)ノーコードでいろいろできると、エンジニアのリソースが空くまでのその場を凌いだり、新しいことについて考えて取り組む余裕を作り出すことができます。今回はそんなお話の一つです。

餅は餅屋ですから専門家から見れば拙いやり方をしている面もあるかとは思いますが、そこは何卒多めに見ていただければ幸い。

NPSアンケートのチェック運用をツールの自動連携で効率化する

本日のテーマは、BigQueryのデータをデイリーでAirtableに自動連携して、BUYMAのNPSアンケートチェック業務の効率化をした話を書きたいと思います。

NPS(ネットプロモータースコア)とは、EC事業者にとっては定番ですが、取引が完了したお客様に任意で答えていただいている、BUYMAの顧客のロイヤリティを計測するためのアンケート調査です。

NPSの回答には、BUYMAや出品者様のお取引についての貴重なご意見が詰まっています。エニグモではNPS専用のプロジェクトチームを部門横断で結成し、回答を全てチェック、アクティブサポートや今後の開発・施策の検討に活用させていただいています。

これまでのNPSチェック運用と課題

NPSプロジェクトでは、毎日いただく数百のアンケートすべてに目を通し、気になるものをピックアップして定期MTGで今後の対応を議論しています。チェック作業は曜日ごとにプロジェクトメンバーで分担して行っているのですが、それがなかなか手間のかかる作業となっており、また、チェック対応結果の活用という点でも課題が多くありました。

いままでのやり方

Gmailにデイリーで送られる回答データを確認し、Gmail上に対応ログを残す

f:id:enigmo7:20211207173705p:plain
Gmailで送られるNPSアンケートのサマリ例

課題
  • チェックに時間がかかりすぎる
    • データに直接チェック結果を書き込むことができないので、気になったポイントなどをメモする場合コピペしてドキュメントを整形する作業を毎回する必要がある
  • ピックアップのMTGで決まった対応について、進捗管理がしづらい
    • 議論のログがメールにしかないため、対応担当者が手元で別途タスクリストを作ったり情報をメモしておかないといけない
  • アンケートに答えていただいた方へご連絡する際に、メールリストの作成をするだけで一苦労
    • 手動で再度データを抽出し対応の一覧をExcelで作成、そこからメール送信対象者をチェックしてSQLでメールアドレスリストを抽出……と複雑な手順が必要だった
  • どのアンケートについてどういった議論が起こり、どんな対応を行ったのか、あとから振り返る手段が限られている
    • Gmailの履歴から検索してひとつひとつ中身を見るしかないため、一覧性が低い
    • どんなご意見がどのくらいの件数来ているのかなどを定量的に把握することができない

……といった具合に、NPSアンケートを導入した初期から続く古の運用のため、やりづらいポイントが多数ある状態でした。

チェック運用のツールをGmail→Airtableに移行し効率化

そこで、以下の2点について解決すべく、運用改善を行いました。

  1. アンケートのチェック作業の手間を減らす
  2. 今後の対応のために過去のデータを参照しやすくする

購入者様がアンケートに回答→メンバーが内容チェック→対応内容を決定しタスクリストを作成→進捗管理→回答者様へのご連絡→過去のピックアップ内容の振り返り、という一連の業務の流れを整理し直して、最小限の手数で作業が完了できるようにします。そのために、チェック作業のツールをGmailからデータベースサービスAirtableに移行することにしました。

Airtableは、一言で言うときれいで賢いスプレッドシートです。表形式のデータを直感的に、かつ自由度高く扱うことができるため、多数のデータをチェックして分類したり、追加の情報を手動でメモして管理したり、ということがGoogle Spreadsheet やExcel以上に簡単に見栄え良くできます。

今回はBigQueryから抽出したNPSアンケートのデータを自動でAirtableに同期し、そこでピックアップ運用を全て完結させるようにしました。やることは単にデータの自動同期なのですが、使用ツールが変わるとチェック作業は劇的にやりやすくなります。

※ 今回の記事ではAirtableの細かい使い方については割愛しますが、使いこなせれば本当に便利すぎるサービスなので、おすすめです!

▼参考記事

Excelスプレッドシート!WebプロジェクトのためのAirtable活用術

loftwork.com

使用ツールと自動化の流れ

今回の自動化の流れと使用したツールは以下になります。 f:id:enigmo7:20211207175057j:plain

Google BigQuery

NPSアンケートの回答データが溜まっているDB

Google App Script(GAS)

BigQueryから毎日自動でデータ抽出しスプレッドシートに転記するプログラムを作成

Google Spreadsheet

GASで抽出したデータを一時的に溜め、Zapierのトリガーにするために使用

Zapier

複数のWebサービスを組み合わせて独自の自動ワークフローを作成できるタスク自動化ツール。Spreadsheetに同期したデータをトリガーにして、Airtableに自動転記する設定を作成

zapier.com

Aitable

自動同期したデータの目視チェック、対応ログの記録などを行う

airtable.com

GASでBigQueryからデータ抽出してシートに記入

GASを使ってBigQuery上でSQLを動かし、スプレッドシートにデータを転記するまでのコードは以下のような感じです。これは社内の他のGASやググった内容を参考にあれやこれやしてなんとか作りました。GAS、もう数年間書いては忘れを繰り返しているのですが、今回の対応でやっと結構定着した気がします……。

GASは深夜の3時に毎日起動し前日分を取得するようトリガー設定をしています。

//BigQuery
var projectNumber = 'データ抽出元の任意のプロジェクトNo'; 
 
//スプレッドシート
var ss = SpreadsheetApp.getActiveSpreadsheet();
 
//SQLの結果を出力するシート
var sheetNPS = ss.getSheetByName('任意のシート名');
 
function check() {
  //処理開始メッセージ
 Browser.msgBox("処理をしています。しばらくお待ちください。")
 
 //SQL結果書き出しシートのクリア
 sheetNPS.getRange(2, 1,sheetNPS.getLastRow(),sheetNPS.getLastColumn()).clearContent();
 
 // SQLを生成 
 
var sql =  "任意のクエリ";
 
Logger.log(sql);
 
 // SQLを実行する準備
 var query_results;
 var resource = {
   query : sql,
   timeoutMs: 1000000,
   // Standard SQLを使用する場合はLegacySqlの使用をfalseにする
   useLegacySql: false
 };
  try {
 // SQLを実行
   query_results = BigQuery.Jobs.query(resource,projectNumber);
 }
 
 // エラーが発生したらログ出力、メッセージ出力して終了
 catch (err) {
   Logger.log(err);
   Browser.msgBox(err);
   return;
 }
 
 while (query_results.getJobComplete() == false) {
   try {
     query_results = BigQuery.Jobs.getQueryResults(projectNumber,query_Results.getJobReference().getJobId());
     if (query_results.getJobComplete() == false) {
       Utilities.sleep(3000); //以下の謎のエラーがでたら、この値を増やすか、timeoutMsの値を増やす。「ReferenceError: 「query_Results」が定義されていません。」
     }
   }
   catch (err) {
     Logger.log(err);
     Browser.msgBox(err);
     return;
   }
 }
 
 Logger.log(query_results);
  var resultCount = query_results.getTotalRows();
 var resultValues = new Array(resultCount);
 var tableRows = query_results.getRows();
 
 // 抽出結果を配列(resultValues)に格納
 for (var i = 0; i < tableRows.length; i++) {
   var cols = tableRows[i].getF();
   resultValues[i] = new Array(cols.length);
   for (var j = 0; j < cols.length; j++) {
     resultValues[i][j] = cols[j].getV();
   }
 }
 
 // 配列(resultValues)の内容をシートに出力
 sheetNPS.getRange(2,1,resultCount,tableRows[0].getF().length).setValues(resultValues);
 
Browser.msgBox("完了したよ!") //完了メッセージ
Zapierの設定

次に、スプレッドシートのデータ更新をトリガとしてAirtableにデータを転記するZapierを設定します。 当初はGASでそのままAirtableに転記することを想定して作り始めたのですが、AirtableのAPI Documentをよくよく見ると、一度にAPIで追加できるレコードは10まで、という制限があり、数百行を一気に追加したかったため間にZapを挟むことにしました。

f:id:enigmo7:20211207175259p:plain
トリガにスプレッドシートのレコードの追加もしくはアップデートを指定

f:id:enigmo7:20211207175329p:plain
先ほど作ったGASのシートを指定

f:id:enigmo7:20211207175342p:plain
1行1トリガとして先ほど抽出したデータがテストに出てきます

これでトリガー設定は完了です。

次に、Airtableに転記をするための設定をします。

f:id:enigmo7:20211207175410p:plain
APPにAirtableを選択しアカウントを連携

f:id:enigmo7:20211207175443p:plain
Create Recordを選択

f:id:enigmo7:20211207175529p:plain
あらかじめ作成したAirtable上のカラムとスプレッドシートのデータの対応を設定

f:id:enigmo7:20211207175541p:plain
テストで入力内容を確認し、問題がなければZapをONにする

f:id:enigmo7:20211207175421p:plain
データを同期したAirtableシート

これで、毎日前日分のデータをAirtableに自動同期することができます!!

Zapierのトリガ数の上限を解除する

と、うまくいったかと思いきや、運用開始してみたら問題が発生。

Zapierは動作したトリガ数に応じて課金されていく仕組みなので、誤作動防止のために一度に動くトリガが100件を超えると自動でストップする仕様になっていました。今回作ったZapは毎日数百件ある更新データをひとつひとつトリガとしてZapを動かす設定のため、動かすたびに毎回Zapが止まってしまいました。一時停止したZapはボタンひとつで再開作業をすれば問題なく動くのですが、毎朝対応が必要になるのでこれでは自動化の良さが半減してしまいます。

最初は解決方法がわからず、毎日再開をするひと手間をしばらく続けていたのですが、やっぱりめんどくさい!!

Zapierに問い合わせてみると、トリガ上限を上げてもらえることが判明。リミットを500に設定してもらい、無事に完全自動化することに成功しました。

よく調べるとトラブルシューティングにも該当の内容がありました。サポートはすべて英語なので該当箇所を見つけるのも一苦労ですね……。

zapier.com

新・チェック運用

毎日のデータをAirtableに連携することで、フィルター機能で自由に表示を操作することができるようになったので、アンケートのチェック運用もAirtable上でスムーズに行えるようになりました。 まずは、曜日ごとに分担してチェックをしているため、回答を担当曜日ごとに表示できるようそれぞれのViewを作成。

f:id:enigmo7:20211207175956p:plain
曜日ごとの表示例

担当曜日の回答に目を通して気になるものにはチェックマークをつけていき、コメント欄にメモを記入するだけでピックアップは終了です。

MTGではチェックがついたものについて確認して議論し、決まった対応内容を購入者様・出品者様それぞれの該当欄に記入して対応の進捗や担当者もここで管理します。

f:id:enigmo7:20211207175839p:plain
対応タスクの一覧View

更に、回答いただいたお客様へのご連絡のためのリストもAirtable上のViewで管理できるため、改めてデータ出しをする作業もなくなりました。

f:id:enigmo7:20211207180035p:plain
メール配信のためのリスト

運用変更の結果、実現したこと

今回のツールの変更と自動化で、課題だった以下の点について解決し、かなりの効率化を実現することができました。

  • MTG前のピックアップのための所要時間が削減でき、1日分のチェックに以前は1時間前後かかっていたものを30分程度でできるようになった
  • MTG中にチェックしたものがそのまま対応のタスクリストになるため、進捗が管理しやすくなり対応時間の削減と対応漏れの防止ができた
  • 過去に出てきた類似案件の対応見直しや、特定の出品者様・購入者様に過去どういったご意見が多いかをMTG中にさっと参照することができるようになったため、以前よりも的確なフォローが可能になった
  • 回答者様への一斉連絡のためのメールリスト抽出が自動で完了できるようになり、手動でリストを作成する手間がなくなった

今後の展望

今回の一番の目的だった業務の効率化は十分に達成することができた一方で、データ活用という観点ではまだまだやれることがあると考えています。

現在は個別のアンケートへの対応のみに活用されていますが、ピックアップしたデータを再度データベース内の他の情報と組み合わせてより高度な分析の材料としたり、特定の回答内容の方にのみMDツールと連携して自動でご案内をしたり、などデータが整ったことで活用の幅を広げることができるはず。貴重なお客様の声をサービス改善に繋げるべく、今後も試行錯誤を続けていきたいと思います。

明日の記事の担当は人事総務グループの右川さんです。お楽しみに!


株式会社エニグモ すべての求人一覧

hrmos.co

AWS移行のため、大規模で複雑な負荷テストをやった話

はじめに

こんにちは、インフラエンジニアの 高山 です。

この記事は Enigmo Advent Calendar 2021 の 9 日目の記事です。

現在、BUYMAをオンプレからAWSへ移行するプロジェクトを進めています。 テスト環境の移行は完了し、本番環境の移行をしようというところです。

本番環境の移行をする前に 性能的に問題ないことを確認するため、本番環境と同程度のスペックで検証環境を構築し負荷テストを実施しました。 まだ終わっていませんが、今の時点で得た知見を記事にしようと思います。

負荷テストツール選定

詳細は割愛しますが、 以下のような要件からAWSの分散負荷テストのソリューション(正式名称はDistributed Load Testing on AWS 以下、AWS負荷テストソリューションと呼ぶ)を使うこととしました。

  • 大規模な負荷テストができること
  • 複雑なテストシナリオが作成できること
  • 情報が多いこと
  • 学習コストや構築運用コストが低いこと
  • 費用が安価であること
  • テストシナリオをコードで管理できること

AWS負荷テストソリューションは それ自体の情報は多くないものの JMeterの設定ファイルを読み込むことができるためテストシナリオ作成の情報は多く、テストシナリオをコードで管理できること以外は要件を満たしています。 (ruby-jmeterを使えばコード管理できそうですが、手は出しませんでした。)

AWS負荷テストソリューションの概要

AWS負荷テストソリューションは AWSのマネージドサービスを組み合わせたAWSのソリューションの1つで、AWSが提供しているCloudFormationのテンプレートからスタックを作成すれば、簡単に作成することができます。

導入の説明などは割愛します。 以下を参考にしてください。

テストシナリオ作成

性能的に問題ないことを確認するためには 本番環境と同等の負荷をかける必要があります。 本番環境でのアクセスが多い機能と ログイン/未ログインの割合を調べ、それをテストシナリオにしました。

本番環境の確認

  • ログイン割合

    • 未ログイン状態: 8
    • ログイン状態: 2
      • ログインページへのアクセスは300アクセスに1回程度
  • アクセスの多い機能の割合

    • Web検索: 70
    • Web商品詳細: 90
    • API検索: 35
    • API商品詳細: 35

作成したテストシナリオ

本番環境の確認結果より、1/5の確率でログインするようにしつつ アクセスが多い機能を任意の割合でアクセスするようなテストシナリオを作成しました。

f:id:enigmo7:20211206134416p:plain
作成したテストシナリオ

JMeterの詳細な設定方法などは割愛しますが、ポイントは以下になります。

  • 割合を近似してできるだけ小さい数にした
  • インタリーブコントローラでログインの割合をコントロールするようにした
  • 会員IDリストのcsvファイルを用意してランダムにユーザを変えてログインするようにした
    • (事前に同じパスワードでログインできるように仕込んでおきました)
  • アクセスの割合で特に多い機能のHTTPリクエスサンプラーを割合の数だけ作成
    • さらにそれを5回ループし、ログインを300アクセスに1回程度になるようにした
  • 同じ機能でも、HTTPリクエスサンプラーごとに URLリストファイルを分割して別々のcsvファイルを参照するようにした
    • (同じcsvファイルを使うと、同一スレッドの同じ回で同じURLになってしまったため)

AWS負荷テストソリューションにJMeterの設定ファイルを読み込ませる

f:id:enigmo7:20211206134742p:plain
ファイルアップロード

作成したテストシナリオで外部ファイル(csvファイルやプラグインファイル)を読んでいる場合は、zipにまとめてからアップロードします。

  • ポイント
    • 外部ファイルは相対パスで指定すること
    • テストシナリオの拡張子はjmxとして、複数のjmxファイルは含めないこと
    • ファイルサイズ上限は50MB

AWS負荷テストソリューションのイメージとスクリプト

使用されているコンテナのイメージと実行されるスクリプトを確認してみましょう。

Dockerfileを見ると、コンテナイメージはtaurusを元にしています。 ENTRYPOINTに指定されているスクリプトの中でアップロードしたファイルを読み込んでいる箇所を見てみます。

# download JMeter jmx file
if [ "$TEST_TYPE" != "simple" ]; then
  # Copy *.jar to JMeter library path. See the Taurus JMeter path: https://gettaurus.org/docs/JMeter/
  JMETER_LIB_PATH=`find ~/.bzt/jmeter-taurus -type d -name "lib"`
  echo "cp $PWD/*.jar $JMETER_LIB_PATH"
  cp $PWD/*.jar $JMETER_LIB_PATH

  if [ "$FILE_TYPE" != "zip" ]; then
    aws s3 cp s3://$S3_BUCKET/public/test-scenarios/$TEST_TYPE/$TEST_ID.jmx ./
  else
    aws s3 cp s3://$S3_BUCKET/public/test-scenarios/$TEST_TYPE/$TEST_ID.zip ./
    unzip $TEST_ID.zip
    # only looks for the first jmx file.
    JMETER_SCRIPT=`find . -name "*.jmx" | head -n 1`
    if [ -z "$JMETER_SCRIPT" ]; then
      echo "There is no JMeter script in the zip file."
      exit 1
    fi

    sed -i -e "s|$TEST_ID.jmx|$JMETER_SCRIPT|g" test.json
  fi
fi

スクリプトから以下のことが わかりました。

  • jmxファイルをfindで探しているようなので、zip内のjmxファイルのパスは気にしなくても良さそうですが、含めるjmxファイルは1つだけにする必要がある
  • プラグイン用のjarファイルをJMETER_LIB_PATH配下へコピーしていますが、zipを解凍する前に コピーしているので プラグインは追加できない模様
    • (今回のテストシナリオで使用した追加プラグインRandom CSV Data Set Configのみなのですが、zipに含めなくても使えました 謎です)
$ docker run -it --rm --entrypoint "bash" public.ecr.aws/aws-solutions/distributed-load-testing-on-aws-load-tester:v2.0.0
root@1328dc7cdfdd:/bzt-configs# ls -l
total 1296
-rwxr-xr-x 1 root root   1210 Sep 30 04:16 ecscontroller.py
-rwxr-xr-x 1 root root   1360 Sep 30 04:16 ecslistener.py
-rw-r--r-- 1 root root  16542 Sep 30 04:19 jetty-alpn-client-9.4.34.v20201102.jar
-rw-r--r-- 1 root root  19600 Sep 30 04:19 jetty-alpn-openjdk8-client-9.4.34.v20201102.jar
-rw-r--r-- 1 root root 320564 Sep 30 04:19 jetty-client-9.4.34.v20201102.jar
-rw-r--r-- 1 root root 214251 Sep 30 04:19 jetty-http-9.4.34.v20201102.jar
-rw-r--r-- 1 root root 164646 Sep 30 04:19 jetty-io-9.4.34.v20201102.jar
-rw-r--r-- 1 root root 565135 Sep 30 04:19 jetty-util-9.4.34.v20201102.jar
-rwxr-xr-x 1 root root   2998 Sep 30 04:16 load-test.sh
root@1328dc7cdfdd:/bzt-configs# find ~/.bzt/jmeter-taurus -type d -name "lib"
/root/.bzt/jmeter-taurus/5.2.1/lib
root@1328dc7cdfdd:/bzt-configs# find /root/.bzt/jmeter-taurus/5.2.1/lib -type f -name "*jar" -ls | wc -l
109
root@1328dc7cdfdd:/bzt-configs# find /root/.bzt/jmeter-taurus/5.2.1/lib -type f -name "*jar" -ls | egrep -i "csv"
root@1328dc7cdfdd:/bzt-configs#

分析のための準備

f:id:enigmo7:20211206160828p:plain
Datadog ダッシュボード

AWS負荷テストソリューションのテスト結果レポートを見ても詳細な分析はできないので、詳細な分析をするためには監視ツールで必要なデータを取る必要があります。 今回はDatadogを使い、以下のようなデータを確認できるようにダッシュボードを作成しました。(APMも使っています)

  • 各機能(DBやアプリサーバ、検索サーバ、キャッシュサーバ等)の負荷
  • アプリやLBのbusy/idle worker
  • キャッシュサイズ、キャッシュヒット率、eviction
  • etc

目標値

f:id:enigmo7:20211206153419p:plain
本番環境のある日のスループット/分

負荷テストした結果を分析できても、どの程度の値であればOKと判断できなければ意味がありません。 本番環境のスループットやページごとのレイテンシを調べておき、目標となる値を調査しました。

移行により性能を向上させるというよりは 現状より性能が低下せず移行できることを目標にしているため、 今回は現在の本番環境のスループットやページごとのレイテンシが そのまま目標値となります。

本番環境の全LBサーバのログを合計したところ、ピークタイムのスループットは5万アクセス程度/分でした。 (CDNを利用しているので、オリジンのアクセスのみの計測値です)

負荷テストの流れ

以下のような流れを繰り返し、問題を解決しながら 負荷テストを進めていきました。

  • 同時接続数を少ない数から始め、各サーバの負荷やbusy/idle worker数、スループットを見ながら上げていく
    • workerが枯渇する前に 各機能の負荷やレイテンシが上昇してしまう場合は、その原因を調査して解消
      • (設定のミスや構成的な問題、単純なスペック不足など 低レイヤーから高レイヤーまで様々な問題がでてきました)
  • 問題が解消しworkerが枯渇した状態でも、目標となるスループットに達しない場合は アプリサーバの台数を増設
    • (アプリサーバの台数を増やすと、また別の場所がボトルネックになる場合があるため、徐々に台数を増やします)
  • 目標となるスループットに達しても、各機能の負荷やレイテンシが高くなっていなければOK

AWS負荷テストソリューションの設定値

f:id:enigmo7:20211206161621p:plain
設定項目

Concurrencyは どのように設定すれば良いのか?

  • Task Count : <タスク数(コンテナ数)>
  • Concurrency : <タスク毎の同時接続数(ユーザー数)>

合計の同時接続数(合計ユーザー数)はTask Count x Concurrencyになります。 Task Count=1でConcurrencyを増やせば安上がりなのですが、負荷をかける側にも負荷がかかるので そうはいきません。 Concurrencyの推奨制限は200になっていますが、ECRの負荷を見ながら調節する必要があります。

これはドキュメントユーザー数の決定の項目が詳しかったので、ドキュメントを参照してください。

Ramp Upって必要?

Ramp Upは負荷テスト開始時の暖気運転のためだと思っていて、ずっと0に設定して負荷テストしていたのですが 暖気運転以外でもRamp Upを設定した方が良いケースに遭遇しました。

f:id:enigmo7:20211209094154p:plain
Ramp Upの設定で解消した定期的な負荷上昇

BUYMAではアプリケーションサーバとしてPHPRuby on Railsを使用しています。 PHPで処理している機能は少ないのですが、ログイン処理はPHPを使用しています。

ログイン処理は300アクセスに1回程度ですが、Ramp Upを設定しないと負荷テストのすべてのスレッドが同じタイミングでログイン処理をしようとするため、定期的に負荷が上がるような不可解なグラフになったと考えられます。 (時間経過とともにスレッドごとのタイミングがずれていくため、徐々に解消されていきます)

Ramp Upを設定したところ、定期的な負荷上昇はなくなりました。

どれくらいの時間、負荷テストするべきか?(Hold for)

f:id:enigmo7:20211207135714p:plain
長時間負荷テスト

あたりまえですが、テストをする環境や どんな負荷テストをしたいかにより、どれくらいの時間 負荷をかけるべきか変わってきます。

例えば 徐々にキャッシュがたまり、キャッシュヒット率が上がるにつれてDBへの問い合わせが減っていく様子を確認するため 長時間の負荷テストを実施しました(上記のグラフです)。 キャッシュされた状態でのテストをしたい場合はURLリストを少なくして負荷テストしました。

何をテストしたいかによって 設定を変更したり、負荷テストの時間を調節する必要があります。

AWS負荷テストソリューションの問題

Failed to parse the results.

f:id:enigmo7:20211207203019p:plain
Test Failed

長時間負荷テストを実施したり、何回もテストを作成すると AWS負荷テストソリューションのテスト結果レポートが表示されず、Failed to parse the results.になることがあります。 その場合は 負荷テストはできいて、レポート作成処理に失敗しているだけのようです。

ERROR    finalResults function error ValidationException: Item size to update has exceeded the maximum allowed size

CloudWatch Logsで確認したところ 原因はDynamoDBの制限超過エラーのようなのですが、サポートへ問い合わせたところ 仕様だそうです。

分析は主にDatadogを使用しているため、あまり支障はありませんでした。

ダッシュボードからテストシナリオが消えていく

f:id:enigmo7:20211207202649p:plain
test-400,test-700,test-800が消えたダッシュボード

何個もテストケースを作成して負荷テストしていくとなぜか、ダッシュボードからテストシナリオが消えていくことがあります。 テストシナリオの数ではなく、テストの回数か何かに制限があるようです。

消えたテストシナリオでもタブが残っていれば/URLを覚えていれば、設定が残っていて、テスト実行も可能でした。 こちらはそんなに困らなかったので、サポートへ問い合わせはしていません。

サポートに項目がない

f:id:enigmo7:20211209090705p:plain
サポート その1

f:id:enigmo7:20211209090759p:plain
サポート その2

明らかにDynamoDBの問題の場合はサポートに問い合わせできたのですが、AWS負荷テストソリューション自体の問題の場合は サポートのサービスに項目がありませんでした。

AWS負荷テストソリューション自体の問題は SAの方に聞いてみましょう。

AWS負荷テストソリューションのAPI

30分おきにTask Countを変更して負荷をかけていて APIあったらいいなと思っていたのですが、 今回 ドキュメントを見返していたら、APIありました。 見逃してました。

設定を変えてながら連続して負荷テストするような場合はAPIを使いましょう。

最後に

今回は大きなサービスの移行のための負荷テストで、テスト環境では発生しなかった問題が次々と発生するなど いろいろと大変でした。 負荷テストツールにはあまりコストをかけたくなかったので、AWS負荷テストソリューションを使うことで だいぶラクができたと思います。

これから、バッチサーバなどが動いてDBに負荷をかけている状態でも 性能的に問題ないかなどを確認し、自信を持って本番環境の移行に臨めるようにしていこうと思っています。

明日の記事の担当は カスタマーマーケティング事業本部の 杉山 さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co

プロジェクトを運用しての学び8つのこと

はじめに

こんにちは、サーバーサイドエンジニアの@hokitaです。

この記事は Enigmo Advent Calendar 2021 の 8 日目の記事です。

今回はテックリードスクラムマスターとして約8ヶ月間プロジェクトを運用していく中で学んだことを8つ紹介したいと思います。

学び

1. ストーリーポイントと難易度

例えば2ポイントのストーリーがあり、経験の長いAさんは2日、初心者のBさんは4日かかるとします。では作業量が倍と見積もった4ポイントのストーリーはどうでしょうか。Aさんは倍の4日でできたのですが、Bさんは始めての作業だったので3倍の12日かかってしまいました。このようにスキルや難易度によってポイントと工数が単純な比例関係にならないことがよくあります。そのため、メンバーのスキルを認知しつつ、どのような策をとるべきか考える必要がありました。

  • 納期が迫っているなら、Bさんが苦手なタスクをAさんにやってもらう
  • スケジュールに余裕があるなら、Bさんに苦手なタスクをやってもらいながらスキルアップを目指す
  • Aさんに余力があるなら、Bさんを手伝ってもらう(ペアプロを実施するなど)

それぞれメリット・デメリットあるので、その時々で判断する必要があるかと思います。

2. フル稼働ではなく1名助人役になる

プロジェクト開始時は私を含めた2名で開発していました。そのときのベロシティは約7ポイントで、途中で人員を増やし4名になってからは約14ポイント消化できるようになりました。人数が倍になったからベロシティも倍になったと思うかもしれませんが、そうではなく、私はあまり開発をせずに助人役に回っていました。実際には下記のようなことを行っていました。

  • 進捗が著しいタスクを発見して対策を考える
  • ストーリー着手前に一緒に設計を考える
  • コードレビュー
  • 手が空いたときには小さめのストーリーを消化

私が開発に集中することもあったのですが、進捗は逆に低下することが多かったです。開発中に発生する問題は思った以上に工数を膨らませます。それを解消する役がいることで安定した開発スピードを出すことができると気づきました。

3. レビューファースト

スクラム開発ではスプリント内で成果物を残し、ステークホルダーへデモを見せフィードバックを貰うことが重要です。よくあったのが、一人で同時に複数ストーリーを進めて、結局どのストーリーもスプリント内に終わらせることができなかった、というものです。なぜそのようなことが発生するかというと、レビューを返すまでに時間がかかっていることが原因でした。レビュー依頼を出した開発者はレビューが返ってくるまでは他のストーリーに着手するかと思います。そうしているうちに複数ストーリーのマルチタスクとなって、結局どのストーリーも消化できずじまいとなってしまいます。レビューはなるべく早く返して、1つのストーリーを確実に終わらせることが大事です。

4. ベロシティがプレッシャーに

良くなかったなと反省しているのですが、1on1の時に各開発メンバーに「次のスプリントでは○ポイントの消化を目指しましょう」とストーリーポイント基準で目標を設置していました。数値目標で管理しやすいと思っていたのですが結果どうなったかと言うと、コードの品質が下がり、時には仕様を満たないプルリクがくることもありました。個人のコーディングスピードはいきなり上がるものではないので、時間を省くとなればデバッグ時間となっていたのだと思います。それに気づいた後は、まずは安定したアウトプットができること、そして、ポイントはあくまで目安に過ぎないことを意識し、目標は消化ポイントとは別のものに変更しました。

5. レトロスペクティブが自己評価になりがち

レトロスペクティブではメンバーそれぞれがKPT法で書いていました。そこでよく上がってくるものは「〇〇の実装で時間がかかってしまった。なので、〇〇を勉強する」のような自己評価が多かったです。個人の能力を伸ばすことも重要ですが、どちらかというとチームとしてなにができるのかを議論することのほうが重要だと思っています。「〇〇で時間がかかった。」のは相談する機会がなかったのが原因なら「朝会で相談する(相談しやすくする)」やスキルが足りない場合は「詳しい人とペアプロの時間を設ける」というのが良い振り返りかと思います。これは開発している本人だと気づけないことが多いので、他の人が提案してあげることが望ましいです。

6. スプリント内で終わらないストーリーは放置しない

前述したとおりスプリント内で成果物を残すことは重要なことですが、どうしてもストーリーを消化できないことは多々発生します。ストーリーの粒度を小さくすることは心がけていたもののどうしてもそうはならないストーリーもありました。ほとんどの場合次の週にも継続して開発するのですが、なぜ終わらなかったのかを調査し対策することが大切です。例えば一人で行き詰まって終わらなかったのなら、次週はペアプロでそのタスクを最優先で終わらせる、もし思っていた以上に作ることがあった(例えばAという機能を追加するのに実はBという機能を作る必要があったなど)なら、まずストーリーを分解することはできないか、他のメンバーと役割分担はできないか、今のまま続けるとしたらどのくらいかかりそうか、など念入りに調査し対策を考えます。これを怠ると何スプリントにもまたがるストーリーになる可能性があり、開発者のメンタルを下げ、負のスパイラルに陥ることが多い印象です。

7. 早くリリースしたいなら機能を削る

まず下記をスクラムチーム全員で認識を合わせる必要があるかと思います。

  • 開発スピードが劇的に向上しないこと
  • 最初に作成した仕様の大半は不要な機能であること

それを前提に納期へ向けてできることといえば「機能を減らす」もしくは「納期を伸ばす」しか手段はないかと思います。今回のプロジェクトもバックログを全て消化するにはベロシティなどの数値から計算して目標納期に間に合わせるのは「不可能」でした。やったことと言えば、優先度の低い機能をごっそりと削ることでした。リリース後にその削った機能を開発したかというとほとんどの機能は「不必要」でした。

8. 興味を引くスプリントレビューを

本プロジェクトはバックエンドのメンバーが多かったので、デザインは後回しにして簡素なページを作成していき、デモでは毎週動くものを提供していたのですが、ステークホルダーからのフィードバックが薄いことに気付きました。その後デザインが当たった段階でやっといろいろな意見をいただけるようになりました。動けば良いというものではなく、もしデザインファーストなプロトタイプを作っていればもっと早い段階で多くのフィードバックを貰えたかと思われます。機能やステークホルダーのよりけりだと思いますが、興味を引くようなプロトタイプを作ることも重要だと気づきました。

最後に

いかがだったでしょうか。まだまだ未熟ですが、今回の学びを次回のプロジェクトへ生かしていこうと思います。

明日の記事の担当は インフラエンジニア の 高山 さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co

決定木分析を使用して、データ分析を行った話

こんにちは、エニグモでデータアナリストをしている井原です。

この記事は Enigmo Advent Calendar 2021 の 7日目の記事です。

今日は、実際に業務で、データ分析をした内容を元に、データアナリストがどのような仕事をしているのかをお話したいと思います。

データアナリストの仕事

世の中では、データアナリストと言われる職種の仕事は多岐に渡ると思います。

データマイニング、データ分析基盤の整備、ビジュアライゼーション、KPIの設計、機械学習モデルの構築、etc...

エンジニアリングやサイエンスの領域と思われるところを担っているデータアナリストの方もいらっしゃるのではないかと思います。

エニグモの場合、データサイエンティストやデータ基盤エンジニアといった、専門家が在籍しています。そのため、データアナリストは、施策の効果検証やサイト上の課題発見といった、ビジネス領域の課題に対して、データ分析で解を出す仕事にフォーカスすることが多いです。

また、エニグモは、データ分析のリテラシーが高く、データアナリストではないディレクターといった職種の人でも、SQLを回して、データの抽出/分析を行うことが普通の文化になっています。

データアナリストとしては、データ分析の設計や手法を深く理解して、アウトプットを出していくことのやりがいを感じながら、仕事の出来る環境になっていると思います。

決定木による売れ筋商品の分析

ここからは、実際に分析した例を元にして、分析手法として使用した決定木分析について、お話したいと思います。

課題

エニグモが運営しているBUYMAでは、CtoCの売買を仲介するプラットフォームビジネスを行っています。

そのため、ECサイトとして、購入者だけではなく、販売を行う出品者に対してのフォローも行うことが必要です。出品者の方に良質な商品を出品していただくことで、売り場としての魅力が向上し、購入者にとっても、良質なサイトになっていくと考えられます。

しかし、良質な商品とは何なのか?想像するものは、人それぞれで異なると思います。そこを、定性的な感覚だけでなく、定量的なデータ分析を行うことで、売り場にあるべき商品を定義し、出品者の方に出品促進を行っていきたい、というのが、今回の課題でした。

分析方法の選定

良質さを決める要素は数多くあると思いますが、今回はまず、基礎的な分析として、商品のブランド、カテゴリ、モデル名の中で、どのような商品が売れているのかを調査しました。また、売れている商品、の定義については、ビジネス側のメンバーと議論のうえ、CVR(出品された商品数のうち、販売された商品数の割合)としました。

早速、日別のデータを取得し、BUYMAの中で主流となるジャケットカテゴリに絞ると、以下のようなデータが確認できます。
※なお、記事内で取り扱っているデータについては、全て、ダミーデータとなります。

f:id:enigmo7:20211129101224p:plain

BUYMAで取り扱われているブランド、カテゴリ、モデル名は、数が多く、クロス集計などで解釈することは困難と思われます。

ブランド、カテゴリ、モデル名をそれぞれ単体で集計することも可能ですが、その場合、あるブランドのCVRが高いと、どのようなカテゴリ、モデルでも高いのか?といった解釈が難しくなります。

今回は、CVRに対して、ブランド、カテゴリ、モデル名といった特徴のうち、どの要素の影響が大きいのか?を分析したいですので、可視性が高く、解釈性のよい決定木分析を使って、分析してみることにしました。影響の大きさを見るには、重回帰分析といった手法もありますが、決定木分析であれば、要素の掛け算(このブランドのこのモデルのCVRが高い、といった見方)も確認できます。
※厳密には、重回帰分析でも要素の掛け算を変数とすることで、出来ないことはありませんが。

実装

pythonを使って、実装していきます。

先ほど取得してきたデータのうち、ブランド、カテゴリ、モデル名、を説明変数とし、CVRを目的変数として予測する決定木モデルを作成します。

環境:
Windows 10 Pro
Python 3.9.9

1.ライブラリのimport
必要なライブラリをインポートします。

import pandas as pd  
import subprocess  

# 可視化を行うためのライブラリ  
import matplotlib.pyplot as plt  

# 回帰の決定木モデルを作成するためのライブラリ  
from sklearn.tree import DecisionTreeRegressor, export_graphviz  
from sklearn.model_selection import train_test_split  
from sklearn.metrics import r2_score  
from sklearn.metrics import mean_absolute_error  

2.データの読み込み
pandasでデータを読み込みます。

df = pd.read_excel("cvr_data.xlsx")[["date", "brand", "cate_name", "model", "listing_count", "sell_count"]] # 必要な列に絞る

# データの確認
print(df.head())
print(df.columns)

3.移動平均に変換する
ECデータの場合、平日より休日の方が多く売れる傾向がありますので、7日間移動平均に変換して、データを均します。
ローデータは、前の要素の9/30の次に次の要素の9/1が来てしまうため、9/7以降のデータに絞り込みます。
※もっとよいやり方がありそうな気がしますが、自分の知識だとこうなりました。

df["listing_count"] = df["listing_count"].rolling(7).mean()
df["sell_count"] = df["sell_count"].rolling(7).mean()
df = df[df["date"] >= "2021-09-07"]

# データの確認
print(df.head(30))

# CVRを計算して、カラムを追加
df["cvr"] = df["sell_count"]/df["listing_count"]

4.変数をダミー変数に変換
今回、使用する予測変数は、全て質的データになるので、そのまま、決定木分析に使用することは、出来ません。
get_dummies関数を使って、ダミー変数に変換します。

df = pd.get_dummies(df, drop_first=True)

# 2の時点と異なることを確認
print(df.columns) 

5.データの分割
予測変数と目的変数、学習用データとテスト用データに分割します。
今回は、モデルの精度を上げることは目的としていないため、テストデータは少なくして、ほとんどのデータを学習データにしました。

exclusion_list = ["cvr", "date", "listing_count", "sell_count"]
include_list = [column for column in df.columns if column not in exclusion_list]

obj_df = df["cvr"]
exp_df = df[include_list]

obj_array = obj_df.values
exp_array = exp_df.values

X_train, X_test, Y_train, Y_test = train_test_split(exp_array, obj_array, test_size=0.01, random_state=222)

6. 決定木モデルの学習
作成したデータで、決定木モデルを学習させます。

# モデルのインスタンス生成
reg = DecisionTreeRegressor(max_leaf_nodes=20)

# 学習によりモデル生成
model = reg.fit(X_train, Y_train)
print(model)

# 評価
y_true = Y_test
y_pred = model.predict(X_test)
print(r2_score(y_true, y_pred))
print(mean_absolute_error(y_true, y_pred))

7. 木構造を画像に保存
モデルの木構造を解釈できるよう、画像に変換します。

dot_data = export_graphviz(model,
    out_file="cvr_data.1.dot",
    filled=True,
    rounded=True,
    feature_names=exp_df.columns
)

subprocess.run("dot -Kdot -Tjpg -Nfontname='MS Gothic' -Efontname='MS Gothic' -Gfontname='MS Gothic' cvr_data.1.dot -o cvr_data.jpg".split()) # 日本語を含むと、文字化けするため、fontを指定

解釈

以上のソースコードを実行すると、以下のような決定木のjpgファイルが出来上がります。

f:id:enigmo7:20211129101816j:plain

出来上がった決定木を見ながら、解釈をしていきます。
注意点として、決定木は、lossが少なくなるように分割していくアルゴリズムであるため、上位に出てくる変数が、必ずしも、CVRを高くする変数とは限りません。
valueを確認しながら、どのような分割がなされているか、確認していきます。

  • まず、カテゴリ_jacketGが最初のノードで分割されるようになっています。
    そして、右に分割されたノードのvalueは0.108と左の0.024のノードよりも高いため、カテゴリが、カテゴリ_jacketGの場合、CVRがかなり高くなると解釈できます。
  • では、カテゴリ_jacketGであれば、なんでもよいかというと、その次の分割を見てみると、ブランドがブランド_BNである場合、valueが0.142、そうでない場合は、0.006となっているため、カテゴリ_jacketGは、ブランド_BNが一強のカテゴリであることが分かります。さらにノードを下ると、ブランド_BNの中でもモデルによって、CVRは異なるようですが、全般的には、高いCVRを擁していることが見てとれます。
  • カテゴリ_jacketGではないノードを見ていくと、いくつかのブランド名でノードが枝分かれするようになっています。カテゴリ_jacketGでなければ、その次は、ブランドの選択が重要である、ということが見てとれます。実際には、企画担当者と会話をしながら、表示されているブランドをグルーピングなどして、整理しました。
  • さらに深く確認しようと思えば、ブランド_BFは、カテゴリによって差がある、カテゴリ_jacketBカテゴリかどうかで、ノードが分かれる、と状況に応じて、確認していくことも可能です。

決定木の場合、初めにも話した通り、視覚的に分析結果を表せるため、ドメイン知識が少なくても、結果の解釈が行いやすいことはメリットではないかと思います。また、企画担当者側も分析結果が分かりやすいので、スムーズに相談が行いやすくなると思います。

なお、解釈性が高い決定木分析ですが、注意点もあります。

まず、決定木分析は機械学習アルゴリズムの中では、精度が高くなりにくい、と言われています。これは、モデルが学習データに過学習しやすく、汎用性が低くなってしまうためです(今回は、生データや、感覚値ともずれていないという判断をして、精度はあまり重視しませんでしたが。)。決定木に限りませんが、あくまでも学習データとして使用したものの説明にしかなっていませんので、将来的にも同じ傾向があるかどうかは、確実ではありません。特に、一時的に強い需要があったデータなどが含まれると、当然、そのデータの影響が強く出てしまうため、注意が必要です。

今後の展望

今回は、比較的、カジュアルな分析でしたので、そこまで、多くない変数で実施しました。感覚としては、企画担当者側も理解しやすかったのではないかと感じましたので、決定木を使ったデータ分析は有用であると考えています。

変数を増やしていくことで、目的とする変数に対して、どういった変数が影響を与えているのか、さらに詳細な分析を行うことも可能と考えられます。

また、決定木アルゴリズムの発展形として、LightGBMやXGBoostなどのアルゴリズムが、データサイエンス分野では、スタンダードになっているようです。他にも、SHAPなど、今回、実施した内容以外で、機械学習モデルの可視化をする方法が研究されており、自分も現在、勉強中です。

最初にお伝えした通り、エニグモには、データアナリストとは別に、データサイエンティストの職種もあります。データサイエンスのプロフェッショナルがいて、通常のデータ分析を企画担当者の方も普通に行っている環境ですので、ビジュアライゼーションやモデルの説明性といった手法を使って、データとビジネスをうまくつなげていくのが、データアナリストの役割ではないかと考えています。

本日の記事は、以上です。読んでいただき、ありがとうございました。

明日の記事の担当はエンジニアの沖田さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co

OhMyZshからZinitに乗り換えてみた話

この記事は Enigmo Advent Calendar 2021の6日目の記事です。

こんにちは。BUYMAでWebエンジニアをやっております、岡本です。エニグモに入ってから約1年が経過しました。

学生の時にプログラミングを始めてしばらくbashを使っていたのですが、イケイケの先輩にzshを教えてもらい、zshの機能を拡張するためのプラグインマネージャーにはOhMyZshを勧められ、数年利用していました。この時期に使っていたmacOSの標準シェルはbashで、zshはわざわざインストールするものでした。

1年前にエニグモに入社した頃、支給されたMacBookProに搭載されているmacOSの標準シェルは既にzshになっていました。(標準シェルがzshになったのはmacOS 10.15 Catalina以降です)

せっかくの機会なので気持ちを新たにプラグインマネージャーも替えてみようと思い立ち、いくつかzshプラグインマネージャーを調べたところZinitが良さそうに思えたので導入することにしました。今回は導入した感想を軽く綴ってみようと思います。

github.com

(2021年12月6日閲覧、以下記載URLも同様)

いいところ

いくつか公式ドキュメントでもアピールされていますが、個人的に良いと思うところを挙げます。

zshの起動が速くなる

公式ドキュメントで喧伝されているのがzshの起動スピードについてです。早いことをアピールしています。

Zinit is currently the only plugin manager out there that provides Turbo mode which yields 50-80% faster Zsh startup

訳(Zinitは現在、ターボモードを提供する唯一のプラグインマネージャーであり、Zshの起動が50〜80%速くなります。)

cf. https://github.com/zdharma-continuum/zinit#zinit

ここでターボモードとは何ぞやとなるのですが、waitを使った遅延読み込みのことを指すようです。これについては後ほど紹介しますが、ターボモードを使わなくても従来のプラグインマネージャーと比較するとzshの読み込み速度は高速になっていると思います。

OhMyZshおよびPreztoプラグインとライブラリの読み込みをサポートしている

OhMyZshやPreztoなどを利用していた方もその資産を継承できます。なお私はOhMyZshのプラグインは現在は読み込まずに使っています。気になる方は以下をご覧ください。

参考URL

https://zdharma-continuum.github.io/zinit/wiki/INTRODUCTION/#oh_my_zsh_prezto

使ってみよう

Zinitのインストール方法はこちらからご覧いただけます。

https://github.com/zdharma-continuum/zinit#automatic-installation-recommended

sh -c "$(curl -fsSL https://git.io/zinit-install)"

source ~/.zshrc

zinit self-update

こちらを実行することで~/.local/share/zinit/zinit.gitにzinitがインストールされ、zshrcにzinitの設定が追加されます。

ここから自分好みにカスタマイズしていくわけですが、現時点の私のzshrcを見てみるとこんな感じになってました。1~7行目はromkatv/powerlevel10kを使うために設定しているものです。一言でいうとターミナルのUIをカラフルにしてくれるものです。ペアプログラミングの機会があると「ターミナルのUIがカラフルですね」とよく言われます。ディレクトリ名やブランチ名が見やすいのでOhMyZshを使っている時からずっと使っています。

  1 # Enable Powerlevel10k instant prompt. Should stay close to the top of ~/.zshrc.
  2 # Initialization code that may require console input (password prompts, [y/n]
  3 # confirmations, etc.) must go above this block; everything else may go below.
  4 if [[ -r "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" ]]; then
  5   source "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh"
  6 fi
  7
  8 ### Added by Zinit's installer
  9 if [[ ! -f $HOME/.zinit/bin/zinit.zsh ]]; then
 10     print -P "%F{33}▓▒░ %F{220}Installing %F{33}DHARMA%F{220} Initiative Plugin Manager (%F{33}zdharma/zinit%F{220})…%f"
 11     command mkdir -p "$HOME/.zinit" && command chmod g-rwX "$HOME/.zinit"
 12     command git clone https://github.com/zdharma/zinit "$HOME/.zinit/bin" && \
 13         print -P "%F{33}▓▒░ %F{34}Installation successful.%f%b" || \
 14         print -P "%F{160}▓▒░ The clone has failed.%f%b"
 15 fi
 16
 17 source "$HOME/.zinit/bin/zinit.zsh"
 18 autoload -Uz _zinit
 19 (( ${+_comps} )) && _comps[zinit]=_zinit
 20
 21 # Load a few important annexes, without Turbo
 22 # (this is currently required for annexes)
 23 zinit light-mode for \
 24     zinit-zsh/z-a-rust \
 25     zinit-zsh/z-a-as-monitor \
 26     zinit-zsh/z-a-patch-dl \
 27     zinit-zsh/z-a-bin-gem-node
 28
 29 ### End of Zinit's installer chunk
 30 以下略

さて、プラグインを設定してみます。zshを使う上で外せないのは補完とシンタックスハイライトではないかと思います。

# 補完
zinit light zsh-users/zsh-autosuggestions
# シンタックスハイライト
zinit load zdharma/fast-syntax-highlighting

ここで、プラグインの読み込み方法はloadlightがあります。

loadを使うことでプラグインのトラッキングを可能にします。zinit report {plugin-spec}プラグインの情報を出力し、zinit unload {plugin-spec}プラグインを無効にします。

一方lightを使うとプラグインのトラッキング機能が無効になり、loadに比べて読み込み速度が速くなるようです。

またsnipetを使うと、URLを直接指定する形でプラグインを読み込むことができます。

zinit snippet https://gist.githubusercontent.com/hightemp/5071909/raw/

cf. https://zdharma-continuum.github.io/zinit/wiki/INTRODUCTION/#basic_plugin_loading

このような感じでお好みのプラグインを追加していきましょう。筆者のzshrcをみるとzdharma/history-search-multi-wordというコマンド履歴を検索するプラグインが入っています。

zinit load zdharma/history-search-multi-word

f:id:enigmo7:20211202102734g:plain

設定例はzinitのドキュメントに記載されているので私はこれを参考にして設定したのですが、ここでiceというものが出てきます。

zinit ice pick"async.zsh" src"pure.zsh"
zinit light sindresorhus/pure

zinit ice depth=1; zinit light romkatv/powerlevel10k

zinit iceは直後の行で実行されるloadlightの挙動を変更します。iceの後のpicksrcなどはice-modifiersと呼ばれるもので、 iceという名前の由来は、氷は飲み物に入れて少し経つと溶けることから、変更が一時的なものであることを意味すると公式ドキュメントでは説明されています。ice-modifiersの後ろにクォーテーションやイコールで指定されているのは引数です。ice-modifiersが引数によってzinitの挙動を制御します。例えば上記のpickであれば、引数として与えられた"async.zsh"に実行権限を与えてPATHに追加するようにzinitに対して指示しています。

その他のice-modifiersの用法についてはこちらを参照してください。

https://github.com/zdharma-continuum/zinit#ice-modifiers

おわりに

基本的な使い方はできていると思いますが、まだまだ知らないオプションが多いのでもっと使いこなせるように日々精進したいと思います。

次回の記事の担当はデータアナリストの井原さんです。お楽しみに!!


株式会社エニグモ 正社員の求人一覧

hrmos.co