STI、Polymorphic関連を実際に使用した話

こんにちは!サーバーサイドエンジニアの@hokita222です!
有酸素運動は脳を活性化させると聞いて、最近は朝会社に出社せずにランニングしております!

それはさておき、これは Enigmo Advent Calendar 2019 23日目の記事です!

今回は弊社が運営するサイトのBUYMA (Ruby on Rails)に追加した機能で、STIポリモーフィック関連を使ってみたので、どういう設計にしたかを書いていこうと思います。
※使ってみたって話で、それぞれどういう特徴なのかなどの詳しい説明はしておりません。

どんな機能作ったの?

「〇〇キャンペーン」などの施策で、その日あった取引の中で特定の条件(商品ID、カテゴリーID、何円以上など)のものを絞り込み、その対象の取引に対して特定のアクションをさせます。

今回はこの機能の「特定の条件で絞る」の設計を説明していきたいと思います。

設計するなかで実現したかったこと

「特定の条件で絞る」機能を作るなかで重要視していたのは、「条件が増える可能性が高い」ことです。今は商品ID、カテゴリーIDで絞れるけど、将来的にはブランドID、購入数などで絞れるようにするかもしれません。なので条件が容易に追加できることを意識しました。

テーブル設計

テーブル名 説明
promotions 施策テーブル(施策名、施策期間など)
rules 施策の条件テーブル(どの取引を対象にするかの条件)
actions 施策のアクションテーブル(対象の取引に対しての行うアクション)※今回こちらは割愛
rule_targets 施策の条件テーブルとターゲット(itemsやcategoriesなど)との中間テーブル
items 商品テーブル(元々あるテーブル)
categories カテゴリーテーブル(元々あるテーブル)

※promotions, rules, actionsの形はsolidusを参考にさせてもらいました。

STI

f:id:hokita:20191216185531p:plain:w400

rulesテーブル(ruleモデル)に対してitem, category, price_gteというサブタイプクラスを作成しました。

なぜSTI

  • STIだと条件が増えたときにクラスの追加のみで済む。
    • 具象テーブル継承、クラステーブル継承だとテーブルまで作らないといけないので面倒くさい。
  • 将来的に追加されるカラムが少ない。
    • あるサブタイプクラスしか使わないカラムが増えていくと、テーブルにカラムが増えすぎるってこともあると思いますが、今回は問題ないと判断。
  • サブタイプクラスで共通のメソッドでも、それぞれ処理が異なる。
    • 処理が同じなら親クラスで共通のメソッド生やせばすむし、結果enumを使って異なる処理だけを例外的に書けばいいよねってなりますが、今回はそれぞれ異なります。

RailsSTIの機能は使わなかった

Rails標準のSTIの機能は使いませんでした。理由としては「継承」を使いたくないから。今回は委譲で対応しております。(RailsSTIを期待されてた方すみません。)
※と言ってみたは良いものの、Railsから外れるツラミも結構大きいです。今回の機能では問題ないのですが、あまりおすすめはしないです。

ソースコード

# 施策(調整予約)ルールテーブル
#
# カラム
#  - type: どの条件か(どの条件のクラスを使用するか。例: item, category, price_gte)
#  - value: 条件に必要な任意の値(〇〇円以上とか。商品IDとかはここには入らない。)
# 
class Rule < ActiveRecord::Base
  self.inheritance_column = nil

  belongs_to :promotion
  has_many :rule_targets

  delegate :build_relation to: :sub_rule

  private

  # サブタイプクラス
  def sub_rule
    "Rules::#{type.classify}"
      .constantize
      .new(self)
  end
end

継承を使用しないので、サブタイプクラスを呼ぶメソッドを作ってます。

module Subtypeable
  extend ActiveSupport::Concern

  attr_accessor :rule

  delegate :value, :rule_targets, to: :@rule

  def initialize(rule)
    @rule = rule
  end

  # 特定のrelationをactiverecordのメソッドを使用して絞り込む
  def build_relation(relation)
    relation
  end
end

rubyではダックタイピングできるので基本インターフェースは不要ですが、他のエンジニアにもこのメソッド使ってくれという願いを込めてmodule作りました。(ちょっとインターフェース以上のこと書いているのはご愛嬌)

# 商品ルール
class Syohin
  include Subtypeable

  # override
  def build_relation(relation)
    targetable_ids = rule_targets.pluck(:targetable_id)
    relation.where('trades.item_id in (?)', targetable_ids)
  end
end

引数のrelationに取引のRelationを渡すことによって中間テーブルにある対象のIDたちで絞ることができます。(正確にはRelationに条件を付加します。)

# 価格(以上)ルール
class PriceGte
  include Subtypeable

  # override
  def build_relation(relation)
    relation.where('trades.price >= ?', value)
  end
end

こちらは商品ルールとは異なり、rulesテーブルのvalueカラムを使用します。

Polymorphic関連

f:id:hokita:20191216185605p:plain:w400

なぜPolymorphic関連か

  • rule_targetsモデルに対して、複数のモデルを紐付けたかったため。
  • rule_targetsからはitemscategoriestargetsという同類の関係
  • rulestargetsは多対多なので中間テーブルrule_targetsとのPolymorphic関連

ソースコード

# 中間テーブル
class RuleTarget < ActiveRecord::Base
  belongs_to :rule
  belongs_to :targetable, polymorphic: true
end
class Item < ActiveRecord::Base
  has_many :rule_targets, as: :targetable
end
class Cateogry < ActiveRecord::Base
  has_many :rule_targets, as: :targetable
end

※本当はSTIと同じくインターフェース用のmodule作ったほうがいいのですが、共通のメソッドがないので作ってません。

ちなみに

promotionモデルはこうなっております。

# 施策テーブル
class Promotion < ActiveRecord::Base
  has_many :rules
  has_many :actions

  # いろんな条件をまとめたrelationを受け取ることができる。
  def detect(relation)
    rules.inject(relation) do |rel, rule|
      rule.build_relation(rel)
    end
  end
end

Promotionモデルが窓口となっており、コントローラーなどの呼び出し元はdetectメソッドを呼ぶだけで条件での絞り込みが可能になります。

各クラスがそれぞれの仕事を担ってくれているので結構シンプルなのではないでしょうか。またSTI、Polymorphic関連で実装したおかげでif文, case文が全くありません。

さいごに

現状新しい施策条件が増えた場合でもクラス一つ作るだけで解決するので、小一時間あれば条件の追加が可能となりました。

明日は弊社新卒の平井くんです!どんな記事書くんでしょうね。楽しみ!わくわく


弊社では一緒にレガシーコード脱却を目指すエンジニア大募集中です! hrmos.co

GitLabCI+ArgoCDを使って、「マージしたら5分でKubernetesへデプロイ」を実現する

こんにちは。Engimo インフラチームの夏目です。 この記事はEnigmo Advent Calendar 2019の22日目の記事です。

最近はこちらのインタビューでも触れたとおりKubernetesクラスタを作ったり壊したりしていまして、今日の記事はKubernetesにおけるアプリケーションデプロイに関してのお話です。

Kubernetesの継続的デリバリ、どうしてますか?

Kubernetesをプロダクション環境で利用されているそこのあなた!アプリケーションをどうやってデプロイしていますか?

  1. ローカルでDockerImageをビルド
  2. DockerHubのプライベートリポジトリへプッシュ
  3. kubectl editでDeploymentsのイメージタグを最新のものへ変更

といった人の手による温かみのあるデプロイをしている?
それはそれで心がこもった良いやり方かもしれませんが、おそらく少数派ですし、システムに心は必要ありませんのでやめましょう。みなさん基本的にはSpinnakerJenkinsXといったCDツールを利用されているかと思います。

現在私達が開発中のシステムにおいては、デプロイパイプラインのCDツールとしてArgoCDを利用しています。

以下のような特徴があり、シンプルにGitOpsデプロイを実現することができるツールです。

  • GUICLIでmanifestの適用状態が確認できる
  • kustomizeに対応している
  • Sync/Hook機能でデプロイフローを柔軟にコントロールできる

そもそもGitOpsとはどういった概念なのか、という点に関しては提唱元であるWeaveworksのblogがわかりやすいのでご参照ください。

www.weave.works

なお、Weaveworksが開発しているFluxもGitOpsを実現するためのCDツールなのですが、ArgoCDと比べると公式ドキュメントがややわかりづらい印象があります。
また、kustomizeには対応しているものの、他の機能(イメージタグ更新検知)とあわせて利用しようとするとmanifestの構成が複雑になる(選定時の実装では)、といった理由もあって採用には至りませんでした。

EnigmoにおけるArgoCDを使ったGitOpsデプロイフロー

ArgoCDのアーキテクチャは公式ドキュメント記載の以下の図のように、GitOpsの全ての領域をカバーするわけではなく、他のCIツールと併用することを前提としています。

f:id:enigmo7:20191220215726p:plain:w450
ArgoCD architecture

CIツールについては図のようにTravisCIやCircleCI、最近であればGithub Actionsなども候補になるかと思いますが、EnigmoではメインのGitサービスとしてGitLabソースコードを管理しており、GitLabにビルトインされたCI機能であるGitLabCIをフローに組み込みました。

GitLabCIとArgoCDを組み合わせたデプロイフローは以下のような構成です。

f:id:enigmo7:20191220215731p:plain
GitOps DeployFlow

ArgoCDの提唱するBestPracticeに則り、アプリケーションのコードリポジトリKubernetesのmanifestリポジトリは分離させています。
以下にフェーズごとの具体的な流れを書いてみます。

Application Image Build Phase

アプリケーションリポジトリmasterブランチにDockerイメージのバージョンアップMRをマージすると、GitLabCIで以下のジョブが実行されます。

  1. Dockerイメージのビルド・タグ設定
  2. ECRへDockerイメージをPush

これでアプリケーション側のデプロイ準備は終わりました。

Application/Kubernetes Config Deployment Phase

更新したイメージタグを利用するようmanifestファイルを修正し、manifestリポジトリにMRを作成→マージすると、以下のフローで処理が走ります。

  1. GitLabのmanifestリポジトリをCodeCommitRepositoryへミラーリング(参考:GitLab Repository Mirroring)
  2. Kubernetes上で稼働しているArgoCDがCodeCommitRepositoryを参照し、クラスタの状態とmanifestファイル定義を比較
  3. イメージタグがクラスタとmanifestで異なることをArgoCDが検知して、クラスタのDeploymentsを更新

このように、アプリケーションとKubernetes manifestのコードをそれぞれGitにマージするだけで、kubectlを使うことなくデプロイが実行されました。

なお、ArgoCDのチュートリアルや概要的な部分は公式ドキュメントにわかりやすくまとめられているため省略させて頂きます。

argoproj.github.io

ArgoCDを使ったデプロイフローのPros/Cons

Pros

デプロイ作業の省力化

システム開発の初期は冒頭で記載したような、manifestファイルを修正→kubectl diffクラスタとmanifestの差分を確認→kubectl applyクラスタへ適用!という原始時代のようなオペレーションを行っていました。有り得ませんね。

継続的デリバリという方式なので当たり前ではありますが、このフローを構築してからはGitのマージ操作のみでデプロイ作業が完結し、タイトルのようにおよそ5分でアプリケーションがデプロイされるようになりました。

柔軟なデプロイフローの実装

開発中のシステムはRailsアプリケーションで構成されているのですが、Railsアプリケーションにつきもののdb:migrateをどうやって実施するか?という点についても、ArgoCDのSync Waveで解決することができました。

db:migrateを実行するJobを作成し、annotationで他のDeploymentよりも先にSyncが実行されるように設定することで、他のアプリケーションよりも先に確実にdb:migrateが実行され、スキーマ関連のエラーが回避できます。

また、現時点では上記デプロイフローには実装していませんが、Hookの仕組みを使うことでデプロイ後にSlackへ通知を飛ばしたり、正常終了したJobを削除する、といった振る舞いを加えることもできます。

Cons

デプロイスピード

これはArgoCDの短所ではないのですが、このデプロイフローは構成図でもわかるようにオンプレミスのGitLabと、AWSのマネージドサービス群の2つにわかれています。

本来であればArgoCDから直接GitLabのmanifestリポジトリを参照し、GitLabのContainerRegistryからDockerImageをPullするようにすれば、オンプレミスからのイメージPushやRepositoryミラーリングが必要なくなり、もっとシンプルなフローになります。
ただ、AWS側からオンプレミスのGitLabへアクセスするよう設定すると、オンプレミス側への依存が発生することと、KubernetesクラスタからはあくまでAWSのサービスのみとやりとりをするのが、リソースアクセス権限設定の観点からもわかりやすいだろう、という理由からこういった構成になっています。

なお、manifestリポジトリミラーリングはリアルタイムではなくおよそ5分間隔で実行されます。これに加えて、ArgoCDがクラスタとmanifestの差分を3分間隔(調整可能)でチェックしているため、マージしてから最長8分待たないとデプロイされない、という状態になっています。

いざデプロイしようと思ってマージをしてから実際にアプリケーションが更新されるまでの時間がやはり長過ぎるのではないか、という印象は否めないため、前述した理屈を否定する形にはなりますが、GitLabをAWSへ移行してArgoCDから直接参照させる、といった方法の改善を検討しています。

イメージタグ更新修正の手間

デプロイフローに記載したように、アプリケーションリポジトリの更新だけではなく、manifestファイルのイメージタグも都度修正しなければなりません。アプリケーションのコードを更新したら即デプロイ!ということにはならず、もうワンアクションが必要になります。

プロダクション環境であれば、意図しないデプロイを防ぐためにもそういったアクションが挟まるのも良いかもしれませんが、テスト環境のようにスピーディーにデプロイして検証をしたい!という場合は手間でしかありませんね。

ArgoCDの競合プロダクトであるFluxはそういった手間が省略できる、イメージタグの自動検知・適用機能があるため、同じような機能がほしい!というIssueがArgoCDのリポジトリに報告されていました。

FluxとArgoCDは設計思想が違うから、というコメントとともにあえなくクローズされる……かと思いきや、ArgoCDとFluxがそれぞれの機能を統合したGitOpsツールの開発をすすめる旨の発表をしたため、緩やかに期待してArgoCDを使い続けよう、という状況です。

www.weave.works

まとめ

  • KubernetesのGitOpsツールとしてArgoCDを利用しています
  • Dockerイメージのビルドとプッシュ用CIツールとして、GitLabCIを利用しています
  • ArgoCDを利用することで柔軟なデプロイフローを実現することできます

この記事では我々のシステムではどのようにGitOpsデプロイを実現しているか?という構成を紹介させて頂きました。
Kubernetesの運用ツールはまだまだデファクトと呼べるようなものが揃っていない状況ですが、日々新しい機能が盛り込まれたり統廃合される様子は、ウォッチしていて楽しいものでもありますね。

EnigmoではKubernetesをはじめとしたCloud Native Computingの運用に一緒に立ち向かう仲間を募集しています。

hrmos.co


明日の記事の担当はサーバサイドエンジニアの @hokita222 さんです。おたのしみに。

平成Ruby会議01 に登壇しました

こんにちは。サーバサイドエンジニアの伊藤です。

Enigmo Advent Calendar 2019 、21日目の記事です。

先週末の12月14日(土)、平成Ruby会議01 に登壇し、「Play with Ruby」という題で発表してきました。

タイトルからはわかりにくいのですが、parse.y をライブコーディングで操作し、雑な感じに右代入を実装するという話です。

TL;DR

とっても楽しかったです。

内容

RubyKaigi2019 で聴いた

「Play with local vars」by Tatsuhiro Ujihisa/@ujm

トークに影響を受けて、右代入を実装した話です。

詳しくはこちらのスライドを御覧ください。

speakerdeck.com

発表に至るまで

実は今回が初めての人前での発表でした。

2019 年中に人前で発表することが1つの目標だったので、とりあえず CfP*1 を出してみました。

CfP を出した時からずーっと気が休まる暇がなかったように思います。CfP が採択されるまでは心のどこかで却下されることを祈っていましたし、採択されてからは発表当日インフルエンザにでもなってくれないかと思っていたほどです。。。

結果的には有難いことに採択され、幸運なことにインフルエンザにもならず、2019 年中に人前で発表するという目標を達成することができました。

ちなみに、CfP を提出した時点では parse.y をまともに読んだこともありませんでした。右代入の具体的な実装などは皆目見当がついておりませんでした。完全な勢いです。。。

CfP Advent Calendar 2019 というものを発見しましたので、 CfP の内容はこちらに公開してあります。

sean0628.hatenablog.com

こちらを見ていただけるとわかると思うのですが、右代入を実装することはこの時点で決めていました。

ただ、右代入の実装方法が思いつかない最悪のケースが頭をよぎり、チキリにチキリきった結果、発表のタイトルを「Play with right-assignment」ではなく「Play with Ruby」としてしまいました。

わかりにくいタイトルになってしまいすみませんでした。。。

平成Ruby会議01 当日

トークセッション

いろいろな角度からの発表があり、とても刺激的でした。正直、自分の発表のことで頭がいっぱいで、全体の半分くらいしか発表を見ることはできませんでした。。。

聴講だけで、もう一度参加したいくらいです。

個人的に1番印象に残っているのは、金子さんのキーノート「What is expected?」です。 内容はパーサーの話でした。ちょうど1ヶ月前に聞きたかったです。かなり深いところまで解説されていて、理解できなかったところもありました。時間を見つけて復習したいと思います。

発表

さて、自分の発表ですがみなさまのおかげで全体的にはスムーズに進めることができたかなと思っております。

今回初めてライブコーディングにも挑戦しました。

スライドのメモ確認のために敢えてミラーリングしてなかったのですが、スクリーンの角度が急すぎてエディターが何も見えないというハプニングが発生しました。 さらには、ライブコーディング中の1番緊張していたタイミングで、 Siri が起動するという奇跡的なトラブルにも見舞われました。

(今年1番 、いや人生の中で1番 Siri に怒りを覚えました。)

そんなトラブルもありましたが、発表を聴きに来てくださった方々が暖かく見守ってくださったお陰でなんとか最後まで辿り着くことができました。 ありがとうございました。

f:id:sean0628:20191217094018j:plain

懇親会

懇親会では RubyRails のコミッタさんやコントリビュータさんから、 OSS との関わり方や RubyKaigi の裏話など貴重なお話を伺うことができました。

また、キーノートスピーカーの金子さんからは、右代入の実装に関して具体的なアドバイスをいただきました。 今回いただいたアドバイスをもとに、また Play したいと思います。

最後に

運営・スポンサー・参加者の皆さん、ありがとうございました。

みなさまのおかげで素敵な1日を過ごすことができました。

f:id:sean0628:20191217094002j:plain

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

hrmos.co

*1:Call for Proposal

LINE Front-end Framework(LIFF) v2でQRコードを読み取るよ

こんにちは!
冬が苦手なディレクターの神吉です。

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

LINEの開発者情報をチェックしていて、ちょっと前にLIFF v2がリリースされていました。
https://developers.line.biz/ja/docs/liff/release-notes/#spy-releasedate_20191016

f:id:enigmo7:20191218130815p:plain:w300

LIFFとは

LIFFとはLINE Front-end Frameworkのことで、LINEが提供するウェブアプリのプラットフォームです。
JavaScriptを書いて開発する感じです。

LIFFはけっこう前から提供されていましたが
LIFF v1 → LIFF v2になって主に下記ができるようになりました。

  • 外部ブラウザでLIFFアプリが動作する。
    • v1はLINE内ブラウザでのみ動作していました。
  • ユーザーのプロフィール情報とメールアドレスを取得できる。
    • LINEログインでできる仕組みに近い仕組みなのかなと思います。
  • QRコードを読み取れる。
    • 新機能ですね!
  • LIFFアプリの動作環境を細かく取得できる。
    • 外部ブラウザで動作するようになって細かい情報を取得する必要がでてきたのかと思います。

LIFF v1もちゃんと触っていなかったのですがこの機会にv2を触ってみたいと思います。
とりあえずLIFFでQRコードを読み取りをやってみます。

事前準備

こちらを事前に準備してください。

LIFFアプリの追加

LINE Developersコンソールよりの 「LIFFアプリを追加」より必要事項を入力してLIFFアプリの追加をしてください。
※今回はQRコードを読み取りをするのでScan QRの設定はONにしてください。

f:id:enigmo7:20191219115402p:plain:w500

またLIFFサーバーAPIでもLIFFアプリを追加することができます。
https://developers.line.biz/ja/reference/liff-v1/#add-liff-app

追加後LIFF URLが発行され、LIFFを開くことができるようになります。

f:id:enigmo7:20191219105854p:plain:w300

LIFFアプリの開発

ここからは実際の開発手順です。

LIFF SDKを読み込み

LIFF SDK https://static.line-scdn.net/liff/edge/2.1/sdk.js を読み込んでください。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>sample</title>
  </head>
  <body>
    <script src="https://static.line-scdn.net/liff/edge/2.1/sdk.js"></script>
  </body>
</html>

LIFFアプリを初期化する

sdk.js 読み込み後に追加してください。

liff
  .init({
    liffId: "XXXXX" // liffIdを指定。line://app/XXXXXの部分
  })
  .then(() => {
    // 処理はここから
  })
  .catch((err) => {
    // エラー処理
    console.log(err.code, err.message);
  });

QRコードを読み取り処理

document.getElementById('qr_button').addEventListener('click', function() {
    if (liff.isInClient()) {
        liff.scanCode().then(result => {
            document.getElementById('qr_text').textContent = result.value;
        }).catch(err => {
            document.getElementById('qr_text').textContent = err.message;
        });
    }
});

liff.scanCode()はLINEのQRコードリーダーを起動し、読み取った文字列を取得するメソッドです。
#qr_buttonにクリックするとLINEのQRコードリーダーが立ち上がり、 QRコードをスキャンすると#qr_textQRコードで読み取ったテキストが挿入されます。

f:id:enigmo7:20191219105829p:plain:w300

注意点

  • iOS版LINEバージョン9.19.0以降では、liff.scanCode()の提供を一時停止しています。(2019/12/18時点)
  • liff.scanCode()は、外部ブラウザでは利用できません。

どう活用する?

手軽にQRコードリーダーを使えるのは良いです。QRの読み取りも速いです。
またLIFFはLINEのuserIdと紐づけてイベントログを取得でき、データ活用できます。
そういったところ活かしオンラインとオフラインをつなぐような施策使えればと思います。
(今のところBUYMAでの具体的な活用案が浮かばずすみません。。)

感想

  • かなり簡単に試すことができました!
  • LIFFを使用する上で実行環境による挙動の違いはしっかり把握しないといけないなと感じました。
  • 今後もLINEの開発者情報はこまめにチェックして、新機能は早めに試してみたいと思います。

参考

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

hrmos.co

ゼロ年代後半ゆるふわ情報系学生がSQLのクラスタリングをやってみた

インフラチームの山口です。 ゼロ年代後半ゆるふわ情報系学生でしたが紆余曲折の末にインフラエンジニア1年目となりました。 今回は編集距離を使用してSQLのクエリをクラスタリングしてみたので記事にまとめてみます。 奇しくも、伊藤直也さんのブログで編集距離の記事が公開されたのが2009年だったのですが、時の流れの速さを感じてしまいます。

1.背景

DBのCPU負荷のスパイク時に、DBのクエリのログを取得・人手で集計して、CPU負荷が高いクエリを改善するという運用を実施することがあります。 ログ(クエリ)の量が少ない場合は良いのですが、大きくなるにつれ、人手での集計に伴い以下のような問題が発生しています。

  • 人手での集計には時間を要する
  • 作業者が変わると結果が一意に決定できない場合があり、集計作業の再現性がない

スクリプトに起こして作業をしようとしても、 単純な文字列一致の方法で集計を試みると、WHERE の条件にしている id が異なっているだけといったクエリをうまく拾えないことが予想されます。 今回は、2つの文字列に対して定義される編集距離(Levenshtein Distance)を使用して、スクリプトでの結果の集計を試みます。 (「TF-IDFやNグラムを使用してやるのもちょっと……」という個人的な気持ちがあったので)

2.関連する事例

「編集距離を使用してクエリ同士の距離を測ればなんかうまくいきそうな気がする」というワンアイディアで調べもせずスクリプトの作成をしてしまったのですが既存の参考文献を調べてみます。 sql query clustering filetype:pdf などで検索してみるといくつか事例が見つかります。 詳しい書誌情報は割愛でタイトルとざっと読んでどんな事やっているかだけ記載します。 クエリのクラスタリングというタスクの論文は書かれているようです。 論文書かれているということは、おそらく他のエンジニアも同じ問題にあたった人もいるはず。 (手でクエリのログ分類して大変な思いしてるのは私だけではなさそう……。)

3.方法

編集距離とは(Wikipedia参照)

編集距離については既存で参考になる記事が複数存在するため、本記事では特に説明せず、Wikipediaからガッツリ引用します。 端的には2つの文字列の間の距離の尺度です。

レーベンシュタイン距離(レーベンシュタインきょり、英: Levenshtein distance)は、二つの文字列がどの程度異なっているかを示す距離の一種である。 編集距離(へんしゅうきょり、英: edit distance)とも呼ばれる。 具体的には、1文字の挿入・削除・置換によって、一方の文字列をもう一方の文字列に変形するのに必要な手順の最小回数として定義される編集距離(Levenshtein distance)は2つの文字列がどの程度異なっているかを示す距離の一種。

kittensitting に変更する場合は、最低でも3回の手順が必要とされるので2単語間の編集距離は3となる。

  1. kitten
  2. sitten(kをsに置換)
  3. sittin(eをiに置換)
  4. sitting(gを挿入して終了)

今回の方法

以下の要件を満たす、なんちゃってクラスタリングを実行するスクリプトを作成しました。

  • クラスタリング結果が一意に決まらない方法は避けたい
  • 手元のマシンですぐに結果がみたい(時間がかかる方法は避けたい)

今回の方法を説明します。 クラスタの代表と以下で記載していますが、実際はレコード中で一番はじめに出てきたものをそのクラスタの代表にしているだけで、特に重心などをとっているわけではないです。 また、今回は、クエリの先頭からN単語分を抜き出して使用します。 これは、「人間が比較する際もクエリの先頭から末尾まで見ておらず、先頭N文字で判断しているだろうという仮定」と、「試しにクエリ全体で計算してみると思いの外時間がかかった」ためです。

今回の方法の概要

- クラスタリング対象のクエリすべてに対して以下を実行する
- クラスタリング対象の1つめのクエリの場合はそのクエリをクラスタ0の代表としクラスタ0に追加する
- クエリと各クラスタの代表のクエリの先頭からN単語の部分文字列との編集距離を計算
  - 編集距離が閾値以下かつ、最も近いクラスタに割り当てる
  - 閾値以下の距離のクラスタがない場合は新しいクラスタを作成する

また、編集距離を取得する際に、一旦クエリをスペースで分割して、単語単位での編集距離を取得します。 日本語だと形態素解析が必要ですが、雑に半角スペースで単語に分割します。

>>> import editdistance
>>> q1 = 'SELECT * FROM tbl_1 WHERE id = 1;'
>>> q2 = 'SELECT * FROM tbl_1 WHERE id = 50;'
>>> q3 = 'UPDATE tbl_1 SET id = 40 WHERE id = 1;'
>>> editdistance.eval(q1.split(' '),q2.split(' '))
1
>>> editdistance.eval(q1.split(' '),q3.split(' '))
6

>>> import Levenshtein
>>> Levenshtein.distance(q1,q2)
2
>>> Levenshtein.distance(q1,q3)
22

パラメータと使用したライブラリおよびスクリプト

  • 使用したライブラリ

    • editdistance (0.5.3)
  • パラメータ(パラメータはエイヤで経験的に決定した)

    • 同じクラスタと判断するための閾値: 10
    • 比較に使用する単語数: 先頭から100単語
      • SQLの先頭100単語を切り出し編集距離を計算する
  • スクリプト

#!/usr/bin/env python3
# coding: utf8
import csv
import editdistance
from collections import defaultdict


# ファイルを読み込む
def load_file(in_fname, skip_header=False):
    queries = []
    with open(in_fname, newline="", encoding="utf8", errors='ignore') as f:
        queries = list(csv.reader(f, delimiter="\t", quotechar='"'))
    if skip_header:
        del queries[0]
    return queries


# 結果をファイルに書き込む
def write_file(out_fname, result):
    with open(out_fname, 'w',  newline="", encoding="utf8") as f:
        writer = csv.writer(f, delimiter="\t")
        writer.writerows(result)


# 半角スペースでクエリを分割して、先頭word_len単語のlistを作る
def get_substr(query, word_len):
    return query.split(' ')[0:word_len]


# クエリの編集距離を計算する
def calc_distance(query_1, query_2):
    return editdistance.eval(query_1, query_2)


# 編集距離を使ってクエリをグループ化する
def cluster(queries, query_pos=1, word_len=100, threshold=10):
    clusters = []
    result = []

    for row in queries:
        current_query = row[query_pos]
        current_query_substr = get_substr(current_query, word_len)

        # 1レコード目の場合は新規のクラスタに入れる
        if len(clusters) == 0:
            clusters.append(current_query_substr)
            row.insert(0, 0)
            result.append(row)
            continue

        # 2レコード目以降
        current_cluster = ""
        # 初期値
        min_dist = float('inf')
        for cluster_id, cluster_query_substr in enumerate(clusters):
            current_dist = calc_distance(cluster_query_substr, current_query_substr)
            # 閾値以下
            if current_dist <= threshold:
                # クラスタとの距離が近ければ所属するクラスタと編集距離を更新する
                if current_dist <= min_dist:
                    min_dist = current_dist
                    current_cluster = cluster_id

        # 閾値以下のクラスタがなければ新規のクラスタにする
        if current_cluster == "":
            clusters.append(current_query_substr)
            current_cluster = clusters.index(current_query_substr)

        row.insert(0, current_cluster)
        result.append(row)

    return clusters, result


# 各クラスタのメンバー数などのサマリを出力
def cluster_summary(clustering_result, cluster_pos=0):
    cluster_members = defaultdict(int)
    for row in clustering_result:
        cluster_id = row[cluster_pos]
        cluster_members[cluster_id] += 1

    # サマリ
    print("### Clustering Summary ###")
    # レコード数
    print("#of records {}".format(len(clustering_result)))

    # クラスタ数
    print("#of clusters {}".format(len(cluster_members.keys())))

    # 要素数上位10のクラスタ
    print("### top 10 clusters ###")
    print("rank\tcluster_id\t#of member")
    for indx, v in enumerate(sorted(cluster_members.items(), key=lambda x: x[1], reverse=True)):
        print("{}\t{}\t{}".format(indx+1, v[0], v[1]))
        if indx >= 9:
            break

    # 各クラスタのメンバー数
    print("### #of member in clusters ###")
    for c_id in cluster_members.keys():
        print("{}\t{}".format(c_id, cluster_members[c_id]))


if __name__ == '__main__':
    # inputfile
    base_fname = 'bombay_queries.tsv'
    base_fname_without_ext = base_fname.split('.tsv')[0]
    in_fname = "data/{}.tsv".format(base_fname_without_ext)

    # parameter
    word_len = 100
    threshold = 10

    # outputfile
    out_fname_result = "result/{}_result_wordlen{}_threshold{}.tsv".format(base_fname_without_ext, word_len, threshold)

    queries = load_file(in_fname, skip_header=True)

    clusters, clustering_result = cluster(queries, query_pos=2, word_len=word_len, threshold=threshold)
    write_file(out_fname_result, clustering_result)

    cluster_summary(clustering_result)

4.結果

データセット

"Similarity Metrics for SQL Query Clustering", 2018. で使用されているのと同じデータセットの中の、bombay_queries.csv を用いました。 データセットにはアノテーションされたラベルが付与されています。

id  label   query
0_1_1   1   select course.course_id,course.title from course
0_1_2   1   select course_id from course
0_1_3   1   select distinct course_id,title from course
0_1_4   1   select course_id,title from course
0_2_1   2   select course_id,title from course where dept_name='comp. sci.'
0_2_2   2   select distinct course.course_id,course.title from course where course.dept_name='comp. sci.'
0_2_3   2   select course.course_id,course.title from course where dept_name='comp. sci.'
0_2_4   2   select course_id,title from course where course.dept_name = 'comp. sci.'
0_2_5   2   select course_id,title from course where course.dept_name='comp. sci.'

クラスタリングの結果と所感

クラスタリング結果を以下に示します。 結果の評価には、こちらの説明を参照し、PurityとInverse PurityとF値を使用します。 purity,inverse purity, F値は0から1の間で1に近づくほど良い値です。 purityが0.75とやや大きいですが、これはクラスタのメンバー数1のクラスタが多数あるためです。 参照にした資料でも説明がありますが、1クラスタ1レコードにクラスタリングされた場合purityは1になります。 今回の結果では、218クラスタ中でクラスタのメンバー数が1のクラスタは156個ありました。

結果を定性的に確認してうまくできていそうな箇所を無理やり見つけます。 クラスタ108の結果を見ると意図したとおりにクラスタリングできているように見えます。 108の結果をよく見ると、同じクエリですがスペースの差異があるもの( count(*)>1count(*) > 1 )を同じクラスタにまとめられています。ただ、完全文字列一致でやっても、事前に空白除去しておけば拾える箇所なので、編集距離を使った良さとは強く言えなそうです。

また、今回使用したデータセットはとりあえず、クエリにアノテーションされているものを見つけたので、きちんとした理由もなく使っているのですが、そもそも私がクラスタリングしたい目的とデータセット作成時の目的があっているのかなども確認する必要がありそうです。クエリのクラスタリングがうまくできると、嬉しいものの、社内でアノテーションするメンバーを複数人集めて、ラベル付きデータ作って評価するほどのものでも無いような気もするしなという気持ちもあります。

レコード数とクラスタ

#of records|629
#of clusters|218

クラスタのメンバー数降順10クラスタ

rank cluster_id #of member
1 0 127
2 1 33
3 14 31
4 12 22
5 134 16
6 3 14
7 145 14
8 16 13
9 48 10
10 2 8

purity,inverse purity, F値

purity inverse purity F measure
0.75 0.27 0.40

クラスタ108の結果

cluster_id label query
108 9 select student.id,count(id) from (section join takes using (course_id)) natural join student group by id,name having count(id) = 2
108 9 select id,name from section natural join takes natural join student group by id,semester,year,time_slot_id,name having count(*)>1
108 9 select id,name from section natural join takes natural join student group by id,semester,year,time_slot_id,name having count(*) > 1

5. まとめ

編集距離を使用しクエリのクラスタリングを試みました。 クラスタリング方法や、パラメータ、テストに使用したデータセットなど、全体的にエイヤな部分が多いですが、定性的に結果をみると類似したクエリをなんとなくまとめることができたようにも思います。実業務でクエリのログのクラスタリングに使えるかはかなり怪しいのと、DatadogなどのSaaSでもログのクラスタリングはできるようなので、ローカルであえてスクリプト準備してクラスタリングする機会はないのかなとも感じます。

かなり散漫な記事となってしまったので無理やりまとめますが、新しい仕組みがどんどん出てくるなかで、昔からのやり方と新しいやり方適材適所でうまく扱えるようになっていきたいなと思います。 あと、もっと楽に精度良くやれる方法を教えてくれる人がいれば教えてほしいです(かなり切実)。

6. 参考

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

hrmos.co

RSpec を 6 倍速くしてカオスな CI を正常化した話

サービスエンジニアリング本部の山本です。

この記事は Enigmo Advent Calendar 2019 の 18 日目の記事です。

普段はフロントエンド中心の開発をしていますが、たまに DX(Developer Experience) 的なことにも手を出しています。 今回はそんな DX のお話です。

やばい CI

f:id:hi_yamamoto:20191217182336p:plain

エニグモが運営している BUYMARuby on Rails アプリケーションとして動いており、自動テストフレームワークとして RSpec を採用しています。
CIツールとしては Jenkins を採用していましたが、1 年以上の期間、常に Fail しているというエニグモのようなイケてるウェブ企業としてはあるまじき状態が続いていました。
Jenkins は素晴らしいソフトウェアですが、当時動いていた Jenkins のバージョンは 1 系かつオンプレミスサーバーで動いていたこともあり色々と問題を抱えていました。
また、エニグモでは Git ホスティングサービスとして Gitlab を採用していて、ちょうど社内では Gitlab CI を使っていこうという流れもあったため RSpec も Gitlab CI に移行することにしました。

Jenkins 時代の課題

  • ローカルと CI で実行結果が違う
    • ローカルだと通ってたのに CI だと通らない
  • 実行するだけで 2 時間くらいかかっていた
    • 並列実行したいがサーバーのリソースの問題で難しい
  • ブランチごとに実行出来ないため、マージしてみないと CI 上の結果がわからない
    • ブランチごとに実行しようとするとサーバーのリソース不足になる
    • ローカルですべてのスペックを実行するのは非現実的なため関係ありそうな箇所だけローカルでテストしたらマージしてしまう

# jenkins という slack チャンネルがありましたが、失敗しているのが当たり前になってしまっていたため誰も気にしておらず、失敗通知が虚しく響き渡っていました。(割れ窓理論)

どうやって解決していったか

ローカルと CI で実行結果を同じにしたい!

まず、ローカルと CI での実行結果が違う問題について。
これは単純に Docker を使うことにしました。 BUYMA の開発環境はもともと Vagrant を使っていて Docker 化はされていませんでした。 今回はローカルの開発環境全部を Docker 化するのではなく、一旦テスト実行環境だけを Docker 化して CI 上でも同じイメージを利用する方針としました。

Gitlab CI にはジョブを実行する Gitlab Runner というツールを利用します。 Gitlab Runner の実行方式である Executor は Shell 、 Docker などの複数のなかから選択することができますが、今回は後述の実行時間短縮を実現するために並列で実行することを考えて Docker を選択しました。
Docker 上で Docker を動かす Docker in Docker という方式があり、それ専用のイメージも用意されているのでそちらを採用しました。 詳しくはこちら

各ブランチのMR ( Merge Request )ごとに実行したい!

Jenkins 時代は開発用メインブランチである develop ブランチにマージされた時だけテストが走るようになっていたため、 MR をマージして初めてテスト結果がわかるという状態でした。
この場合マージした後に失敗したことに気づき修正する、という流れになりますが、テスト時間が長いこともあり失敗していることに気づかず放置されてしまいます。
マージする前にテストを実行する、という当たり前のことが出来ていない状態だったのです。

これを解決するための Gitlab CI の設定はかんたんで、 .gitlab-ci.yml にこのように記述するだけです。

only: 
 - merge_requests

問題はリソースです。
Gitlab CI への移行当初、 Gitlab Runner サーバー はオンプレミスのサーバーだったため、複数の MR が同時に作成され RSpec のジョブがトリガーされた場合リソースが足りなくなってしまう懸念がありました。 そこで Gitlab Runner サーバー を AWS へ移行して Gitlab Runner の AutoScaling 機能を利用しました。
AutoScaling については こちらの記事が参考になります。

f:id:hi_yamamoto:20191217153040p:plain
移行前の構成


f:id:hi_yamamoto:20191217153141p:plain
移行後の構成

この構成の設計や移行作業は全て弊社インフラエンジニアである夏目さんによるものです。ありがとうございました!!

実行速度を速くしたい!

さて、ブランチごとに実行する環境は整いました。
しかし全てのテストが完了するまで2時間ほどかかってしまうという最大の問題が解決されていません。
MR を作成してマージするぞ!っと思ってからテストが終わるまで 2 時間は流石に待ってられません。
RSpec を速くする方法としては、テストコード内で無駄にレコードを作らないなど色々プラクティスがあると思いますが、数百ファイルのテストファイルを全て見て修正するというのは現実的ではありませんでした。

そこで RSpec の並列化という方法で解決することを考えました。
元々 CI 上でのテストは他のテストファイルの影響を無くすため、通常の rspec コマンドではなくそれぞれのテストファイルを独立して実行するスクリプトを利用していました。

#!/bin/bash

# 全てのスペックファイルを単独で実行するためのスクリプト。

fail_count=0

spec_array=()
while IFS=  read -r -d $'\0' spec; do
    spec_array+=("$spec")
done < <(find spec/ -name '*_spec.rb' -print0)

spec_count=${#spec_array[@]}
echo "Running $spec_count spec files"

export SKIP_CODE_COVERAGE=1

for (( i=0; i<$spec_count; i++ )); do
  echo -e "\n#########################################################################"
  echo -e "\nRunning spec $((i+1)) of $spec_count"
  if ! bin/rspec "${spec_array[$i]}"; then
    ((fail_count++))
  fi
  export SKIP_DATABASE_SEEDING=1
done

echo -e "\n$spec_count test files, failures in $fail_count files\n"

[[ $fail_count -eq 0 ]]

このスクリプトと Gitlab CI の parallel オプションを利用して並列化を実現しました。

まず、 .gitlab-ci.yml のジョブに parallel フィールドを追加します。 今回は 10 個のジョブを並列で実行することにしました。

parallel: 10

この parallel フィールドを追加すると、 ジョブの環境変数として CI_NODE_TOTALCI_NODE_INDEX という環境変数がセットされます。 CI_NODE_TOTAL には parallel で指定した値、 CI_NODE_INDEX にはそのジョブのインデックスがセットされるので、この 2 つの環境変数から実行するファイルを算出します。 並列化後のスクリプトはこちらです。

#!/bin/bash

# 全てのスペックファイルを単独で実行するためのスクリプト。

fail_count=0

spec_array=()
while IFS=  read -r -d $'\0' spec; do
    spec_array+=("$spec")
done < <(find spec/ -name '*_spec.rb' -print0)

spec_count=${#spec_array[@]}
echo "CI_NODE_TOTAL is $CI_NODE_TOTAL"
echo "CI_NODE_INDEX is $CI_NODE_INDEX"
count=$((spec_count / CI_NODE_TOTAL))
start_index=$((count * (CI_NODE_INDEX - 1)))

if [[ $CI_NODE_INDEX -eq $CI_NODE_TOTAL ]]; then
  count=$((spec_count - start_index))
fi

echo "Running $count spec files"
echo "Start at $start_index"

export SKIP_CODE_COVERAGE=1

for (( i=$start_index; i<$start_index+$count; i++ )); do
  echo -e "\n#########################################################################"
  echo -e "\nRunning spec $((i+1 - start_index))(total: $(($i+1))) of $count(total: $spec_count)"
  if ! bin/rspec  "${spec_array[$i]}"; then
    ((fail_count++))
  fi

 
  export SKIP_DATABASE_SEEDING=1
done

echo -e "\n$count test files, failures in $fail_count files\n"

[[ $fail_count -eq 0 ]]

テストを 10 分割することにより 2 時間かかっていたテストが約 20 分で完了するようになりました!

残念ながらジョブ実行用のスポットインスタンスの起動にある程度時間がかかるため、 10 分割で 10 倍速くなるというわけにはいきませんでした。
また、これ以上並列数を増やしても速くなることもありませんでした。

結果

  • ローカル環境と CI 環境での差異がなくなったため、ローカルで成功したのに CI では落ちることがなくなった
  • MR ごとに実行できるようになったためテストが成功した状態でマージでき、メインブランチは常にクリアな状態が保てるようになった (たまに落ちてることもある)
  • テストが 20 分で終わるようになった

所感

長らく不満に思っていた、常に Fail している CI から脱却することができました。
人によるとは思いますが、個人的には CI が落ちて放置されているというような状態に精神が乱されるタイプなので心の平穏を取り戻すことができました。

上記の作業をする前に落ちまくって放置されているテストを全て直すという作業があったのですが正直そこが一番きつかったです。


エニグモではカオスな開発環境を一緒に正常化していきたいエンジニアを募集中です!

hrmos.co

k9sで快適なk8sライフを送ろう!

はじめに

みなさん、こんにちは。 主にBUYMAの検索周り、時々機械学習なエンジニアの伊藤です。

今年もあっという間の1年でしたね。
振り返ってみると多くのことを学ばせてもらい、また成長させてもらった1年でした。
その中でもk8sがやはり自分の中では中心だった気がします。
なので今回はk8sネタで今年を締めくくりたいと思います。

k9sとは?

k9sとはk8s上のリソースを監視、管理するためのCLIツールです。
k8sCLIといえばkubectlデファクトですが、そのI/Fをよりリッチに使いやすくしたのがこのツールです。

f:id:pma1013:20191210142021p:plain
公式のページはこちら

読み方はけぃないんずだと思います(たぶん)
個人的になぜ犬のロゴなんだろうというのがまず引っかかったので軽く調べたところ、 canine=犬のヌメロニム表記であるk9と、kubernetesk8sが表記的に近いから犬なんだなと勝手に納得することにしました。

使ってみる

では実際にk9sをインストールして使ってみましょう。
LinuxOSXWindows上で動作するようですが、今回はMac上でインストールして動かしてみたいと思います。

  • インストール

    brew install derailed/k9s/k9s

  • 起動

    k9s

    表示される情報については大きく2つの領域に分かれており、上部に現在使用しているkubectlのcontext、クラスタ名や、コマンドヘルプ等が固定表示され、
    枠内にpodの一覧など、指定した情報が表示されるようになっています。
    デフォルトではkubectl get podで取得できる項目相当の一覧が見れますが、CPU、メモリ使用率なども表示されます。

  • 基本操作について
    基本的なところさえ抑えておけば、あとは直感的に操作が可能です。

    • コマンドモード
      :でコマンドモードになります。
      続けて、k8sのリソース名(alias名でも可)を指定することでその一覧が表示されます。
      指定するリソース名を忘れた場合はCtrl-aで確認できます。
      例えば、deploymentの一覧を確認したい場合は:dpと入力してEnterでdeploymentの一覧が表示できます。
      Escでコマンドモードを抜けます。
      f:id:pma1013:20191213105637g:plain

    • フィルター
      /のあとにフィルターを指定することで絞り込みが可能です。
      kubectlと同じうように-l app=XXXXで任意のラベルで絞りこみが可能ですし、
      任意の文字列だけ入力してもその文字列がpod名やnode名に含まれれば絞り込みが可能です。
      Escでフィルターを解除します。
      f:id:pma1013:20191213121414g:plain

    • ソート
      Shiftと特定のキーで項目毎のソートが可能です
      各項目とキーのマッピング?で確認できます。
      昇順/降順の切り替えはShift-iで行います。

実践的な使いどころ

基本操作が分かったところで、よりk9sの便利さが伝わるように下記のような場面を想定した操作をしてみます。

  • あるdeploymentリソースに異常があり、該当のdeployment及びその配下のpodのdescribeを確認して、ログを確認する
    f:id:pma1013:20191213121938g:plain

  • podへのport-forward f:id:pma1013:20191213122419g:plain

所感

kubectlに慣れている人や、aliasやpeco等でコマンド操作自体をゴリゴリにチューニングしている場合は、 k9sの使い始めは慣れない分、「そこまで便利か?」っていう感じを受けるかもしれません。 自分は最終的にどちらかに移行するというよりは、下記のような使い分けをすることで落ちついています。

  • 単発の確認はkubectl
    例えば今のpod一覧を知りたい時など、特定の情報だけ知りたい場合はわざわざk9sを起動する方が手間なので、kubectlだけで済ませています。

  • 解析作業や複数のコンテキストを跨る作業
    こういう場合は圧倒的にk9sが便利です。
    上でも記載していますが、クラスタ側に何か異常があった場合には、様々なリソースの情報を見ていくことになると思います。 そういった場合にはk9s上であれば、 わざわざコマンドを打つことなく少ないキー操作で確認が可能です。
    また、複数のkubectlコンテキスト内のリソースを同時にみるのはkubectlではできませんが、
    k9sでは別ターミナルで起動してコンテキストを切り替えることで、
    ターミナルの切り替えだけで複数コンテキストの操作や参照が可能になります。

最後に

k8sとその周辺エコシステムは進化が激しく、日々新しい機能やツールが出てきている印象です。 キャッチアップが大変ですが、今後も日々の運用やシステムの安定性に繋がるようなものは時間を見つけて試していきたいと思います。

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

hrmos.co