一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

一休の1 to 1マーケティングを支えるプラットフォーム

データサイエンス部・大西 id:ohke です。

一休の1 to 1マーケティングを支えるプラットフォームについてお話したいと思います。

1 to 1マーケティング

一休の主力である宿泊予約サービスは今年で19年目、レストラン予約サービスも13年目を迎え、会員数も800万人を超えました。
一休のサービスを「知らなかった」から「知っている」という成熟フェーズに入ってきますと、集客に加えて、1 to 1マーケティングがより重要になってきてます。

一休の1 to 1マーケティングで大事にしていることは3点です。

  • 施策に必要なデータは全てデータウェアハウス (DWH) へ集約する
  • オートメーションツール (内製のWebアプリケーション) でターゲットの抽出、コンテンツの作成、配信を一元管理する
  • ユーザごとにコンテンツを最適化する (レコメンド)

f:id:ohke:20181228105756p:plain

DWHとETL

1 to 1マーケティングでは、ユーザの過去の行動に基づいて施策を実施していきます。
そのため、予約情報 (サービスのテーブルデータ) 、アクセス情報 (Google Analyticsと内製のリアルタイムログ収集ツール1) 、およびそれらと紐付くマスタデータなどは、全てDWHに集約しています。

DWHには、SQL Server (RDS) を使っています。SQL Server Management StudioやRedashでSQLを書けば、誰でも集計・分析を行えるようになります。

DWHへの日々のETL (抽出・加工・格納) は極めて重要になります。
ETLが失敗・遅延すると、データにアクセスできなくなり、1 to 1マーケティングに支障を来してしまいます。マーケティングの文脈以外にも、DWHは、CEOをはじめ各事業部長や集客チーム・UI/UXチームの定点観測や意思決定に利用されるため、データが無い・間違っていることによる影響は甚大です。

一休ではETLを管理するツールとして、Apache AIrflowを導入・運用してきました

user-first.ikyu.co.jp

日次だけで約400タスク (1タスクが1つのテーブルのエクスポートなどと対応します) 、さらに週毎・時毎などのタスクもあわせると、そこそこ大規模なETLとなります。

f:id:ohke:20181228125559p:plain

DWHとETLを健全な状態に維持し続けるために、以下の改善・運用も行ってます。

  • 社内の各部署から依頼されるDWHへのデータ追加や計算方法変更への対応
  • 各部署で自然発生的に持っていたETL処理もAIrflowへ統合
  • 事業担当者 (マーケタや営業) が実行するクエリのチューニング
  • 滞留クエリの自動検知・除去

オートメーションツール

1 to 1マーケティングでは、その名の通り、ユーザ一人ひとりに寄り添ってなければなりません。しかし一方で、アプローチするユーザ数も減らしたくないので、必然的に、少数のユーザに深くターゲットした施策をたくさん実行する必要があります。

1 to 1マーケティング施策の実行にあたっては、大まかに、ターゲットの抽出、コンテンツの作成、配信という3段階の作業を行います。
従来は他社のマーケティングメール配信サービスを使ってきましたが、数十以上の施策を実施するにあたって、大きな壁にぶつかりました。

  • ターゲットやコンテンツに埋め込む値の抽出は、マーケタの日々の手運用に頼られていたため、スケールしない
    • SQLで抽出したCSVファイルをファイルサーバにアップロードする、ということを施策ごとに行っていました
  • 配信は他社のサービス任せとなる (一休でコントロールできない) ため、事故無く運用するためには習熟が必要

また、メールだけではなく、サイトに来訪しているユーザにもアプローチするために、以下の新しいチャネルも要件として生まれていました。これらのチャネルをサービスに密に組み込んでしまうと、施策のたびにマーケタと事業部のエンジニア・デザイナが協同せざるえないため、クイックに施策を実行できなくなります。

  • サイトメッセージ: サイト上でのお知らせやクーポン配布に使われます
  • ポップアップ: サイトメッセージよりも訴求力が強く、クーポン配布に使われます

そこで、ターゲットの抽出・コンテンツの作成・配信を一元管理するオートメーションツール (Webアプリケーション) を内製することで、上の課題の解決を図りました。

  • ターゲットやコンテンツに埋め込む値の抽出を、DWHへ発行するSQLで記述できるようにする2
  • テキストやHTMLを編集できるようにすることで、自由にデザインを変更・プレビューできるようにする
  • 配信のチャネル (メール or サイト上のメッセージ or サイト上のポップアップ) 、日時、優先度などを確認・変更できるようにする
  • 配信は全てオートメーションツールで行うため、基本的には事業部のエンジニア・デザイナとコミュニケーションする必要が無い

f:id:ohke:20181226165711p:plain

オートメーションツールの導入と、その後のマーケタの細やかな施策設計によって、現在では毎日100近くの施策がこのツールから実行されています。

f:id:ohke:20181226163558p:plain

日々の効果測定は、Google Analyticsなどを使って収集し、Redashでビジュアライズして、Slackに通知するというカジュアルな方法を採ることが多いです。

f:id:ohke:20181227145344p:plain

オートメーションツールそのものは以下のアプリケーション構成となっています。

  • サーバサイドは、Python + Flask + SQL Alchemy
  • フロントエンドは、React
  • CI/CDは、CircleCI + CodeDeploy

オートメーションツールからの配信は、チャネルごとに異なる機構で行っています。

  • メールは、サービスと同等の構成をマーケティング用に構えました3
  • サイトメッセージとポップアップは、RDS (SQL Server) に配信リストを渡し、API (Go) 経由でサービスからアクセスすることで実現しました
  • それぞれの配信結果もDWHへETLすることで、開封率の集計や効果測定を行ってます

f:id:ohke:20181228104528p:plain

レコメンド

いくらオートメーションツールで簡単にユーザへ配信できるようになったとしても、そのユーザにとって嬉しい情報でなければなりません。

ホテルやレストランの予約を提供している一休にとって、「そのユーザがどのホテル or どのレストランをおすすめされると喜ばれるか」というのは、サイトでの検索結果やリマーケティング施策では重要なテーマです。特に、ユーザの直近の行動を反映できないリマーケティング施策 (例えば、数ヶ月前に宿泊したユーザを対象としたメール) では、興味を引くコンテンツとなっていないとユーザの心が離れる (要するにウザいと思われる) 要因となってしまいます。

一休では、サイトでの検索結果やリマーケティングメールに表示されるホテルやレストランのリストは、各ユーザの過去の行動を反映したものとなってます。レコメンドと呼んでいます。
具体的には、ホテルやレストランでの行動 (予約やアクセスなど) を元に、アイテムベースの協調フィルタリングを用いて、類似したホテルやレストランをおすすめするというロジックになっています。これらのロジックもDWH上のデータを使って計算しています。

サイトでの検索結果にも同様のロジックでレコメンドしてますが、リアルタイムな情報を使う必要があること、および、サービスに組み込むために堅牢性が求められることから、APIとして提供してます。
このAPIは以下のような構成となっています。こうしたサービスから利用されるアプリケーションの開発・運用もデータサイエンス部の役割です。

  • 実装はPython + Flaskで、全てオンメモリに展開してリクエスト都度で計算してます
  • インフラは、ECS + ElasticBeanstalk
  • CI/CDは、CircleCI

課題は山積みです

今回お話した中でも、まだまだ取り組めていない課題はたくさんあります。

  • DWH/ETLやオートメーションツールの安定化
    • 営業時間前にデータが出揃ってないと、集計・分析や施策の精度が落ちてしまうので、ETLの品質を担保し続けていかないといけません
    • そこまで熟達していないマーケタがSQLやデザインを自由に記述できるので、大きな事故につながらないようにシステムで細やかに予防する必要があります
      • 「検証配信」や「計算する」(件数チェック) はそのための仕組みとなってます
  • 配信タイミングの最適化
    • このユーザには朝8:00、そのユーザには夜7:00など、ユーザごとにチャネルに触れる時間帯が異なる
    • そのユーザがチャネルに触れる時間帯を狙ってアプローチしたい
  • 協調フィルタリングによって売り上げのリフトに成功していますが、更なるUX向上のために別のアプローチを模索中
    • 協調フィルタリングでは、ライトユーザの場合には情報が不足しているため、レコメンドによるリフトが小さくなってしまいます (いわゆるコールドスタート問題)
    • ヘビーユーザでも、ユーザの嗜好が常に一定では無く、ユーザのコンテキスト (目的、一緒に行く人、気分など) を汲み取る必要があります
      • 以前イタリア料理店に行ったからと言って、未来永劫イタリア料理店に行くとは限りません
    • リアルタイムのユーザ行動を反映しながら、その時点でそのユーザにとって最もフィットするホテル or レストランをおすすめできるようになることを目指してます

上に加えて、データサイエンス部としては他にも様々な課題に取り組んでいます。

  • 急増するレストランへの営業活動を最適化する
  • 抽象的なワードでも良い感じにレストランを検索できるようにする
  • クーポン施策の最適化 (損益分岐点の予測など)
  • などなどなどなど...

データサイエンス部では、こうした取り組みを4人で遂行しています (2018/12現在) 。

一緒に取り組んでいただける仲間を募ってます

DWHやETLの改善に燃える!

一休の経営や施策の根幹を成すデータの安定供給のために、自ら設計・構築・運用できます。

内製アプリケーションをクイックに開発・改善したい!

フロントエンド、バックエンド、インフラ、CI/CDを自分で考えて開発できます。

統計モデリングや機械学習でもっとかっこよく課題を解決したい!

高価格帯のサービスECサイトですので、他のサービスと一線を画したテーマの問題に取り組めます。

自ら考えた施策でもっとユーザに愛されるサービスへ育てたい!

CEO直下で施策の立案・遂行・改善に取り組めます。


  1. 詳しい経緯はこちら → データ分析基盤、その後 - 一休.com Developers Blog

  2. 一休のマーケタはSQLを書けます。

  3. 詳しくはこちら → 新メール配信基盤への移行 /ikyu-mail-platform - Speaker Deck

イベント登壇のお知らせ ~1/30(水) 一休com on クラウド ~ 急成長を支える技術基盤とSRE~

こんにちは。今日はイベント登壇のお知らせです。

1/30(水) にマイナビさんが主催する「ITSearch+」のイベントに弊社エンジニアの 徳武(id:s-tokutake) が登壇します。

一休com on クラウド ~ 急成長を支える技術基盤とSRE ~

今回は「技術基盤、SRE」をテーマとして

  • 一休.com / 一休レストランのクラウド移行

を軸に、以降前後でインフラ、技術基盤の仕事がどのように変わってきているか、サービスの成長を支えながら開発生産性をどう上げているのかなどを具体的な事例をもとにお話する予定です。

お申込みはこちらから。

news.mynavi.jp

ご興味のある方はぜひご参加ください。

またイベント開催後に、発表スライドをもとにレポートを書きたいと思いますので、こちらもお楽しみに!

履歴テーブルについて

この記事は一休.com アドベントカレンダーの25日目の記事です。

レストラン事業部エンジニアのid:ninjinkunです。

一休.com及び一休.comレストランはユーザー向けのシステムだけではなく、店舗や一休内の管理者向けの業務システムという性格も持っています。

業務システム経験の無かった自分が一休に転職して最初に驚いたのが、DBに履歴を保持するための履歴テーブルが大量にあることでした。

そこから履歴テーブルの存在に興味と疑問を持ち、社内外のエンジニアと履歴テーブルについて議論してきました。このエントリではそれらの議論をまとめた結果について書いていきます。

履歴テーブルのパターン

まず以下の図をご覧ください。

f:id:ninjinkun:20181225190812p:plain

込み入った図かつ事例が一休特化で恐縮ですが、左上の起点から始まって、右のオレンジの部分が最終的な実装パターンです。

図にあるとおり、たいていのユースケースでは以下の3パターンの実装に落とし込めるのではないかと考えています。

  1. バージョンテーブル
  2. ログテーブル
  3. ElasticSearchやBigQueryなどに放り込む

いきなり「バージョンテーブル」や「ログテーブル」などの単語が出てきましたが、これは自分が「履歴テーブル」を細分化するために使っている言葉です。以下でそれぞれについて説明します。

1. バージョンテーブル

アプリケーションから特定のバージョンを参照する必要があるので、以下の様にバージョン番号を保持するテーブルを作ります。

元データテーブル

価格が変わる商品の例です。IDと最新のバージョン番号を保持しています。

id version price
1 2 1,000
2 1 2,000
3 3 10,000

バージョンテーブル

今までの価格変動がバージョン毎にすべて記録されています。

id item_id version price
1 1 1 1,100
2 2 1 2,000
3 3 1 11,000
4 3 2 10,500
5 1 2 1,000
6 3 3 10,000

2. ログテーブル

基本的にアプリケーションから参照しない前提であり、参照する場合も一覧で確認用に表示するくらいなので、バージョン番号などは不要です。

元データテーブル

id price
1 1,000
2 2,000
3 10,000

ログテーブル

元データテーブルと同じカラム + ログ用の作成日を持つ。更新があったものをどんどん放り込んでいきます。最新かどうかは作成日で判断します。

id item_id price log_created_at
1 1 1,100 2018-11-10 12:00:00
2 2 2,000 2018-11-10 12:01:00
3 3 11,000 2018-11-10 12:02:00
4 3 10,500 2018-11-15 12:00:00
5 1 1,000 2018-11-18 11:00:00
6 3 10,000 2018-20-10 13:00:00

3. ElasticSearchやBigQueryなどに放り込む

こちらのパターンについては一休では採用していないので詳細がないのですが、社外のエンジニアに履歴について相談した際に「うちではこうしているよ」という事例として聞いたものです。

リーズナブルな解決策だと思うので、どこかで採用したいと考えています。

テーブルの詳細

テーブル設計の詳細についても触れておきます。

別テーブル vs イミュータブル

自分が最初に履歴テーブルを見た時に感じたのは「履歴として独立したテーブルは必要なのか?」という疑問でした。履歴として独立したテーブルが無くても、同じテーブルに変更を全て残すように設計すれば、別テーブルはなくても履歴は作ることができます。自分はこれをイミュータブルパターンと呼んでいます。

これの議論については以下のエントリが参考になります。

自分が見ている範囲では、上記エントリの2のパターン、つまりイミュータブルでは無く、別テーブルに元テーブルと同じカラムを作ってを記録するのが最終的に一番穏当な落としどころになるケースが多いようです。

自分が考えるそれぞれのメリット、デメリットは以下の通りです。

メリット デメリット
イミュータブル テーブルが1つで済んで綺麗に見える
書き込みが一回で済む
select する際にmax()で最新の行を絞り込む必要がある(activeフラグなどで解決は可能だが、テーブルロックを掛ける必要が発生すると思われる)
別テーブル 見た目で履歴であることがわかりやすい 2つのテーブルに同時に書き込む必要がある

イミュータブルパターンにもメリットはあるのですが、結局わかりやすさと実装のしやすさから別テーブルパターンが採用されることが多いようです。

データベーストリガー vs アプリケーション側でコピー

別テーブルとして設計する場合、データをコピーする実装にはデータベーストリガーとアプリケーションコード(要トランザクション)の2つの選択肢があります。メリットとデメリットは以下の通りです。

メリット デメリット
トリガー DB側でコピーが自動的に走るために漏れが無い アプリケーションとトリガーに実装が分かれてしまうので後から動きが追いづらい
トリガのデプロイを先にしないとエラーが発生するので、デプロイ時に気を遣う必要がある
コード データの管理が全てコードに集中するので、動きが追いやすい
テストが書きやすい
トランザクションを張り忘れると大変

バージョンテーブルのパターンでは、バージョンテーブルはアプリケーションから参照されるので、アプリケーションに属する機能だと考えられます。この場合はアプリケーションコードで実装する方が自然だと思います。

一方でログテーブルのパターンでは、アプリケーションの機能として捉えるか、ただのログとして捉えるかは解釈が分かれるところです。実装方法もトリガーとコード、どちらも選択肢に入ると思います。自分はトリガはDB側の設定を見に行かないと挙動が把握できないので、極力アプリケーション側で実装したいと思っています。ただ、現場では後から証跡を残して欲しいと言われる事も多く、その場合に短時間で証跡テーブルを実装するにはトリガが選択されることも多いです。

おわりに

初めは違和感を感じていた履歴テーブルですが、これは時系列データのモデリングなのだと考えることで、今では素直に受け入れられるようになりました。

このエントリでお伝えしたいことのほとんどは最初の図に入っていますので、もし良ろしければもう一度ご覧ください。

f:id:ninjinkun:20181225190812p:plain

ここで漏れているパターンも当然あると思いますので、ぜひ皆さんの考える最強の履歴テーブルについても教えていただけると嬉しいです。うちはイベントソーシングで全部解決しているよ!などの事例も知りたいです。

一休における「情シス」の取り組み

この記事は一休.com アドベントカレンダーの24日目の記事です。

qiita.com


社内情報システム部の大多和(id:rotom)です。 一休には2018年8月に入社し、情報システムエンジニアとして、IT を活用した業務改善、オフィス環境の構築を中心とした社内の「情シス」業務全般を担当しています。

本エントリでは、表立って登場することの少ない「情シス」が普段何をしているか、ご紹介していきます。

情シスのお仕事

f:id:rotom:20181220172319j:plain

社内情報システム部は「システム本部」に所属しており、現在 6人 のメンバで業務を行っています。 一休における情シスは以下の2つの側面を持っています。

コーポレートエンジニアリング:社内ツールやシステムの導入及び管理運用、bot やスクリプト開発による業務の効率化などの業務改善の他、オフィスの IT インフラ環境の構築、改善など、IT を活用し、より社員がよりパフォーマンスを発揮できる環境を構築する、いわゆるコーポレートエンジニアとしての業務を行う

ヘルプデスク:入退社に伴う PC やiPhone、iPad などのモバイルデバイスの調達、キッティング、ライセンスの管理や、新入社員への PC のオリエンテーションの他、社員からのPC や社内システム、IT ツールに関する問い合わせや、トラブルシューティング、修理依頼などの短期的な課題解決を行う IT サポートを行う

それぞれの側面から見た一休における情シスの強みと弱み、そして、これから進めていくことについて紹介します。

コーポレートエンジニアリング

強み

一休で利用しているほとんどの社内ツール/システムでは、シングルサインオンかつ2段階認証(2FA)を導入済であり、社員の利便性とセキュリティを両立させています。

オフィスでは高速なネットワークを整備しており、全国の支社においても同一の SSID/パスワードで Wi-Fi を利用することができ、東京本社と同等の環境で業務を行えるように構築しています。

弱み

一休.com一休.com レストラン を始めとする一休のサービスは全てクラウド(AWS)上で稼働しています。 一方で、レガシーな社内システムや一部のファイルサーバはオンプレミスで構築されており、クラウドに移行ができていません。

災害などで東京本社が機能しなくなった際も業務が止まらないよう、社内システムにおいても「脱オンプレ」を行う必要があります。

これからやること

前述の通り、現状行えていない社内システムのクラウド移行を進めています。 また、営業現場からの要望の多い LINE WORKS の導入や、 システムによる受付業務の刷新など社内業務フローの改善に取り組んでいきます。

line.worksmobile.com

ヘルプデスク

強み

PC のキッティングに伴う単純なセットアップ作業はバッチにより自動化されています。また、メーリングリストの作成や PC やモバイルデバイスの MAC アドレス登録などの細かい作業についても、Slack bot にクエリを投げるだけで行えるようになっています。

f:id:rotom:20181221182923p:plain

f:id:rotom:20181221183243p:plain

以前は口頭、電話、Slack と複数の窓口があり煩雑だった問い合わせ対応については、Google フォームによる投稿に一本化し、 必要事項を記入すれば専用の Slack チャンネルへ自動投稿されるように改善されており、情シスメンバが早急に状況を判断し対応できるようになっています。

弱み

PC のセットアップについては多くが自動化されているものの、一部人の手を介す必要のあるものがあります。 現状はキッティング作業を外部リソースへのアウトソースを行っていますが、こちらも自動化を進めなければなりません。

これからやること

今後も事業急成長に伴う社員増大が見込まれます。PC 1台にかかるセットアップの工数を少しでも減らす為の自動化、効率化を継続的に進めていきます。 社内で最もユーザの多い Windows においては Windows Autopilot の導入も視野に入れています。

docs.microsoft.com

社内ツール/システムの紹介

一休では多くのツール/システムが活用されています。その中からいくつかご紹介します。

Slack

slack.com

一休では全社で Slack を導入しており、エンジニアやデザイナーに限らず、営業や事務のメンバともチャット上でコミュニケーションを取っています。 各チームで業務上のやりとりを行うチャンネルの他、勤怠連絡、PC や書籍、オフィス備品の購入依頼や名刺の発注、総務や情シスへの問い合わせを行う専用チャンネルなども用意されています。

f:id:rotom:20181220210321p:plain

また、多くの Slack bot が稼働しており、情シスにおいてはメーリングリストの作成や、入退社に伴うアカウントの作成/停止などの業務は bot により自動化されています。 障害通知を始めとする多くの情報も Slack に連携される仕組みとなっており、社内インフラとして日々利用されています。

「ぽ」は一休で最も多く使われている Slack bot のひとつです。 社員情報検索、会議室予約や、オフィスの座席表や内線番号、Wi-Fi のパスワードなど、今知りたい情報をすぐに検索できる辞書ツールとなっています。

f:id:rotom:20181220205501p:plain

詳しくは以下のエントリで解説されています。

user-first.ikyu.co.jp

G Suite

gsuite.google.com

一休では全社で G Suite を導入している為、 Gmail や Google カレンダー、Google ドライブが業務で利用されています。 また、多くのスプレッドシートが Google Apps Script によりスクリプトが組まれており、 Slack への連携や業務自動化に活用されています。

f:id:rotom:20181220210039p:plain

情シスにおいては、スプレッドシートに入力された人事情報をトリガーとしたアカウントメンテナンスの自動化や、 更新期日の迫ったライセンスや保守契約についてリマインドを行うスクリプトなどを実装し業務の効率化に取り組んでいます。

Azure Active Directory

azure.microsoft.com

Slack や G Suite を含む、一休で利用している多くの社内ツール/システムはAzure Active Directory によるシングルサインオン(SSO)構成となっています。

社内ツール/システムごとにアカウントの ID/パスワードの管理が異なると、社員がパスワードを忘れて問い合わせが発生する、社員の離職に伴うアカウントメンテナンスの工数が大きい、ヌケモレが発生するといった問題が想定されます。

一休では PC にサインインする ID/パスワードでほぼ全ての社内ツール/システムを利用することができます。 また、離職者に対しては Azure Active Directory のアカウントを停止することで、社内ツール/システムへのアクセスを防ぐことができます。

Microsoft Authenticator

Microsoft Authenticator – オンライン アカウントの安全なアクセスと管理

一休では Azure Active Directory による SSO に加え、Microsoft Authenticator による二段階認証(2FA)を行っています。 SSO は複数の ID/パスワードを管理しなくても良い、というユーザの利便性が高い一方で、1つの ID/パスワード が盗まれたときのリスクが高いとも解釈できます。

f:id:rotom:20181220204846p:plain

そこで、一休では各自のスマートフォンに 2FA 用のアプリケーションをインストールして頂いており、アプリケーション側からの承認を得ない限り、 ID/パスワードを知っていても社内ツール/社内システムにはログインできない仕組みをとっています。

Vimeo

vimeo.com

一休では毎月の月初に全社員が東京本社ラウンジに集い、月次の実績報告や社員同士の交流を行う共有会・パーティを開催しています。 各支社のメンバも月初には東京に集まっていましたが、業務都合が付けられずに参加ができないメンバもいました。

f:id:rotom:20160704180322j:plain

また、東京本社で定期的に開催される社内勉強会や、社員向けの説明会にを各支社のメンバに向けてもリアルタイムに共有したいという強いニーズがありました。

そこで、情シスでは動画配信プラットフォームである Vimeo を導入し、東京本社の様子を生中継することにしました。 同様のサービスとしては YouTube Live が挙げられますが、パスワード保護機能を搭載し、よりセキュアである Vimeo を選定しました。

配信機材としては Cerevo 社の LiveShell.PRO を購入することで、HD 画質の高品質なストリーミング配信を実現しました。

liveshell.cerevo.com

Chromebox for meetings

gsuite.google.com

一休では事業急成長に伴い、東京、大阪、名古屋に続き、沖縄、福岡、京都、横浜と次々とブランチオフィスが開設していきました。 事業所が増えることにより、東京本社と各支社間でリアルタイムに Web 会議を行いたいというニーズが高まりました。

Web 会議システムは多種多様にありますが、一休では Chromebox for meetings を導入しました。 この製品は Chrome OS を搭載した Chromebox という PC を Web 会議(Hangout)専用にカスタマイズしたものです。

f:id:rotom:20181221104508j:plain

選定理由として一休では G Suite を導入していることから全社員が Hangout を利用できる状態にあること、 Chromebox for meetings は Hangout に機能を絞ったシンプルなユーザインタフェースであることから導入を決定しました。

IT 部門の担当者が社内ツール/システムを選定する際は、極力安い製品を選んでしまったり、自分たちの IT リテラシー基準で選んでしまったりと、IT 部門目線になってしまうことがあります。 ユーザファーストのカルチャーとする一休の情シスにおいてはユーザである社員にとって使いやすいか、という点を最重視して機器選定を行いました。

尚、現在は Chromebox for meetings は生産を終了しており、後継種の Chrome devices for meetings が販売しています。

最後に

情シスのエンジニアが行っている業務は、事業部でプロダクト開発を行っているエンジニアと比べると地味かもしれません。 しかし、ユーザ(= 社員)と最も近い距離で直接ニーズやフィードバックを聞くことのできる、非常にやり甲斐のある立場であると考えています。

一休においては、多くの業務が社内ツール/システムや bot により自動化、効率化されていますが、未だ人の手を介している業務フローも多く存在します。 そうした社内の業務課題の解決、改善を継続的に行い、社員が「本来やるべき業務に時間を使える」環境を作り上げるのが情シスのミッションです。

これからも一休 情シスは社内の業務改善、サポートを続けていきます!

明日は今年最後のアドベントカレンダー id:ninjinkun による履歴テーブルについてです!

本番リクエストを開発環境に投げる→エラーを検知→修正するというサイクルで開発をすると品質が上がっていくというのを最近実感しました

この記事は一休.comアドベントカレンダー2018の23日目です。

一休.comの開発基盤をやっています akasakas です。

長いタイトルですいません。

本日のお話

本番リクエストを開発環境に投げて、エラーを検知し、修正するというサイクルで開発をすると品質が上がっていくというのを最近実感しました

という話です。

図にするとこんな感じのイメージです

f:id:akasakas:20181215135503p:plain

やっていること

  • 本番リクエストログから抽出
  • 開発環境に対して、リクエストを投げる
  • バグみつかる
  • なおす

細かく 繰り返す

これを繰り返せば、 ちゃんと試験考えなくても、勝手に 品質上がっていくようになるのかなと思いました。

安心してリリースができるのはエンジニアの心理的に非常にいいことなのかなと思います。

具体的にどうやっているのか?

一休ではLogentriesでアクセスログを管理しています。

LogentriesのAPIを使い

  • 本番リクエストを取得
  • 開発環境にリクエストを投げる
  • HTTPステータスコードを確認

というところまでやりたいと思います。

参考実装の前に諸注意

LogentriesのAPIは呼び出し制限があるので、用法用量を守って、正しくお使いください。 詳しくはこちらになります。

docs.logentries.com

参考実装

参考実装がこちらになります。

import logging
import datetime
import requests
import time
import re
import json

settings = {
    'dev_host': 'dev.hostname.com',
    'sampling_seconds': 180,
    'logentries': {
        'endpoint': 'https://rest.logentries.com/',
        'apikey': 'logentries api key', 
        'logs_query': 'query/logs',
        'query_api': 'query',
        'query_statement': 'where(' \
        + '   path = /query_statement(regular_expression)/ ' \
        + ')',
        'logs': [ 
            'logenrties log set key id'
        ]
    }
}

logging.basicConfig(format='[%(asctime)s] %(message)s')
logger = logging.getLogger('real-request-to-development')
logger.setLevel(logging.DEBUG)

def calc_query_time():
    now = datetime.datetime.now()
    from_time = datetime.datetime.now() - datetime.timedelta(seconds=(settings['sampling_seconds']))
    return int(time.mktime(from_time.timetuple())) * 1000, int(time.mktime(now.timetuple())) * 1000


def le_query(from_unixtime, to_unixtime):
    headers = {'x-api-key': settings['logentries']['apikey']}
    body = {
        "logs": settings['logentries']['logs'],
        "leql": {"during": {"from": from_unixtime, "to": to_unixtime}, "statement": settings['logentries']['query_statement']}
    }
    res = requests.post(f'{settings["logentries"]["endpoint"]}{settings["logentries"]["logs_query"]}', headers=headers, json=body)
    
    return res.json()["id"]


def le_longtime_query(id):
    headers = {'x-api-key': settings['logentries']['apikey']}
    res = requests.get(f'{settings["logentries"]["endpoint"]}{settings["logentries"]["query_api"]}/{id}', headers=headers)

    return res.json()


def get_request_url(json_data):
    dev_url_list = []
    for log_row in json_data["events"]:
        log_data = re.search(r'{.*}' , log_row["message"])
        log_json = json.loads(log_data.group(0))

        path = log_json["path"]
        query = log_json["query"]
        useragent = log_json["useragent"]

        dev_url = f'https://{settings["dev_host"]}{path}{query}'

        dev_url_dict = {"dev_url": dev_url, "ua": useragent}
        dev_url_list.append(dev_url_dict)
    return dev_url_list


def send_request(dev_url_list):
    for dev_url_dict in dev_url_list:
        headers = {
            'User-Agent': dev_url_dict["ua"]
        }
        dev_response = requests.get(dev_url_dict["dev_url"], headers=headers)

        if dev_response.status_code >= 500:
            logger.error(f'development url is {dev_url_dict["dev_url"]}')
            logger.error(f'development response status is {str(dev_response.status_code)}')


#########################################
def start():
    from_unixtime, to_unixtime = calc_query_time()
    le_query_id = le_query(from_unixtime, to_unixtime)
    json_data = le_longtime_query(le_query_id)
    dev_url_list = get_request_url(json_data)
    send_request(dev_url_list)


if __name__ == "__main__":
    start()

1つずつ解説していきたいと思います

流れとしては

  1. Logentries API で検索用のクエリIDを取得&実際のリクエストを取得
  2. 実際のリクエストのホストを開発環境用のホストに書き換える
  3. リクエストを投げる&500エラーをログ出力

2.3は難しいことはしていないので、説明は省略します。

Logentries API で検索用のクエリIDを取得&実際のリクエストを取得

Logentries API で検索用のクエリIDを取得

下記で、検索用のクエリIDを取得しています。 このクエリIDをベースにして、実際のリクエストを取得します。

必要なパラメータは

  • logs(どこのログか?ここではアクセスログ)
  • statement(どんな条件か?正規表現でpathを記述するのが一般的?)
  • from,to(unixtimeなので、事前にfrom,toをunixtimeにする必要がある)

です。

詳細はこちらをご覧ください。 docs.logentries.com

def le_query(from_unixtime, to_unixtime):
    headers = {'x-api-key': settings['logentries']['apikey']}
    body = {
        "logs": settings['logentries']['logs'],
        "leql": {"during": {"from": from_unixtime, "to": to_unixtime}, "statement": settings['logentries']['query_statement']}
    }
    res = requests.post(f'{settings["logentries"]["endpoint"]}{settings["logentries"]["logs_query"]}', headers=headers, json=body)
    
    return res.json()["id"]

実際のリクエストを取得

先ほど取得したクエリIDをベースから、実際のリクエストを取得しています。

詳細はこちらをご覧ください。

docs.logentries.com

def le_longtime_query(id):
    headers = {'x-api-key': settings['logentries']['apikey']}
    res = requests.get(f'{settings["logentries"]["endpoint"]}{settings["logentries"]["query_api"]}/{id}', headers=headers)

    return res.json()

実際のリクエストを開発環境に投げることでエラーを検知というところまでできました。ここから、試験と修正を繰り返せば、品質は上がり、安心してリリースできることになると思います。

もう一歩進めて

開発環境が本番相当であると上記の試験はさらに効果が発揮されるかなと思います。

一休では本番DBから個人情報はマスクしたものを開発環境用のDBとして使っています。

本番リクエストを本番相当DBにリクエストを投げることで、より精度の高い試験ができるのかなと思います。

さらにもう一歩進めて

これを本番リリース前のStaging環境でこの仕組みを定期的に実行し、エラーになったら、Slackで通知し、リリース事故を抑止できるようになれば、さらにいいなと思います。 しかし、まだ一休ではここまでできておらず、近いうちにやりたいなと思っているところです。

イメージとしては NginxのMirror moduleやGoReplayといったCanaryReleaseに近いかなと思います。

まとめ

以上、本番リクエストを開発環境に投げて、エラーを検知し、修正するというサイクルで開発をすると品質が上がっていくというのを最近実感しました

という話でした。

明日は id:rotom さんによる『一休における「情シス」の取り組み』です。 普段あまり語られることはない一休の情シス事情について詳しい話が聞けると思いますので、お楽しみに。

参考

tsqllint & Appveyor & AWS CodeDeployで実現するDDL適用自動化

この記事は一休.comアドベントカレンダー2018の22日目です。

qiita.com


データベースに対するDDLの適用、みなさんはどのように運用していますか。
一休では長らく担当者が手動適用をしていました。が、開発者全員の依頼をまとめて、定期的にDDL適用を行うのはかなりの作業負荷です。 そこで、アプリケーションのソースコードと同じようにGitHubとCI/CDのパイプラインを構築して、適用したい開発者が自分で適用できる仕組みを構築しました。

この記事では、その概略を紹介したいと思います。
※当社はMicrosoft SQL Serverを使っているので、その前提の記事になります。

デプロイフロー

f:id:s-tokutake:20181221184621p:plain

  • CIにはAppveyorを、CDにはAWS CodeDeployを利用
    • CodeDeployは後述の通り、外部アクセス用のプロキシサーバも利用
  • GitHubにDDLを管理するリポジトリを作成
  • 開発者はこのリポジトリで新規に適用したいDDLのPull Requestを作成
  • PRをmasterブランチに適用するとAppveyor経由でCodeDeployが起動される。
  • CodeDeploy Agentが動いているddl-controllerマシンでDDLを実行し、SQL Server に適用
    • 適用履歴もSQL Serverのテーブルで管理して同じDDLが2重適用されないように制御
    • 適用結果(実行ログ)はAmazon S3にアップロード
    • Lambdaでログの中身をチェックして、適用結果をSlackに通知

工夫点、注意点

tsqllintでDDLの構文チェック

データベースの定義変更なので、注意深く実施する必要があります。また、実際に定義を適用してみたらエラーになった、となると手戻りが発生して面倒です。
そこで、linterを導入しようと考えました。調べてみるとSQL ServerのT-SQL (Transact-SQL)の構文がチェックできる tsqllintというツールがありました。 動作確認してみたところ、きちんと動作するし、lintのルールのカスタマイズもできるのでこれを採用しました。
そして、これを利用した簡単なNode.jsのスクリプトを書き、Appveyor上で動かして、構文チェックを実行するようにしました。チェックが通らなければ、Pull Requestをマージできないようにします。

※ちなみに、tsqllintはVS Codeの拡張もあるようです。SQL Serverの管理などでT-SQLに触れる機会が多い方には便利かもしれません。

linterでチェックできない点はレビューでチェック

ファイルグループの指定が正しいか、などlinterのチェックだけでは検出できない重要な点があります。 このような点についてはPull Requestのテンプレートにチェック項目を書き出し、Pull Requestの作成者とレビューワーの双方がチェックする、という運用ルールにしました。もちろん、レビューワーのApproveがなければ、Pull Requestはマージできません。 機械的なチェックではないので、多少の不安はありますが、今のところうまくいっています。

インターネットに出れないEC2インスタンスでCodeDeploy Agentを動かすにはプロキシが必要

これは、注意点なのですが、データベースが置かれているネットワークは、インターネットには接続できないようになっているのが一般的です。
一方でCodeDeploy Agentが正常に動作するためには、外向きHTTPSの通信ができることが必須です。
CodeDeploy Agentが外部のAPIを定期的にコールしてデプロイを実行する必要があるかどうか確認しているからです。
この場合、CodeDeploy Agentの構成ファイルに外向き通信をプロキシするためのプロキシサーバのURLを設定する必要があります。
当社では、外向きの通信ができるネットワークにApacheでプロキシサーバを立て、そのURLをCodeDeploy Agentの構成ファイルに設定しました。

Apacheの設定は以下の通りです。

Listen *:8088
<VirtualHost *:8088>
  ProxyRequests on
  AddDefaultCharset off
  AllowCONNECT 443
  <Proxy codedeploy-commands.ap-northeast-1.amazonaws.com>
      <LimitExcept CONNECT>
         Order deny,allow
         Deny from all
        # ↓ CodeDeploy Agent が動いているマシンのIP
         Allow from xx.xx.xx.xx 
      </LimitExcept>
  </Proxy>
</VirtualHost>

CodeDeploy側は、conf.ymlを次のように修正します。
Windowsの場合、 C:\ProgramData\Amazon\CodeDeploy\conf.yml にあります。

---
:log_dir: 'Amazon/CodeDeploy/log'
:root_dir: 'Amazon/CodeDeploy'
:verbose: true
:wait_between_runs: 1
:wait_after_error: 1
:bundle_name: 'artifact_bundle.tar'
:proxy_uri: http://xx.xx.xx.xx:8088 # ここにプロキシサーバのIPを記述

これで、CodeDeploy Agentを再起動すれば正常に動くようになります。

※ 環境によってはセキュリティグループなどネットワークレイヤの調整も必要です。

終わりに

長年、手動でやっていたものを自動化して、果たしてうまく運用が回るか、心配はありましたが、しっかりと運用に乗りました。
データベースをクラウドに移行したという前提条件とアプリケーションのCI/CDの構築経験の応用が実現のキーポイントだったと思います。

※データベースのクラウド移行については、当ブログに詳細な記事がありますので、ご覧ください。

user-first.ikyu.co.jp

user-first.ikyu.co.jp

この記事の筆者について

  • システム本部CTO室所属の 徳武 です。
  • サービスの技術基盤の開発運用、宿泊サービスの開発支援を行なっています。

一休のWebデザイナーとUIデザイナーの違い

この記事は一休.com アドベントカレンダーの20日目の記事です。

qiita.com


はじめまして、宿泊サービスのUIデザインを担当しています河村です。

一休のデザイナーは部署ごとに在籍チームが異なります。私は長い間、営業企画部デザイナーとして働いていましたが、今年4月よりプロダクト開発部UIデザイナーとして働いています。(本ブログでは、前者をWebデザイナーとします)

WebデザイナーとUIデザイナーをやってみて、多くのことが「違った」ので、どんな違いなのかをお話させていただきます。

目次
1) 仕事内容の違い
2) 必要なスキルの違い
3) 仕事の進め方の違い
4) まとめ

仕事内容の違い

ビジュアル表現に特化

一休のWebデザイナーの主な業務内容は、トップページなどの更新作業、施設紹介ページ、特集・企画販促ページ、メールマガジン等の作成です。ページ全体のビジュアルを管理しているので、一つ一つの画像の選定などサイト全体の雰囲気作りに欠かせない役割です。

私は、施設の魅力をユーザーに届けることをゴールにして、施設の世界感を感じさせるページ作りを意識して行っていました。

f:id:kawamuram:20181219114006p:plain

操作性の向上に特化

一方、UIデザイナーの主な業務内容は、ユーザーが「使いやすい」と思うデザインを実現することに特化しています。宿泊プランがすっきり整理された構図、見やすいカラーリング、最適な文字の大きさ、使いやすい検索機能などを意識して、ユーザーにとって使い勝手のいいサイトを目指しています。

基本的には予約のコンバージョン率向上を目的として改善を繰り返しますが、予約には関係ない部分でも使いやすさや見やすさを良くする為の改善もあります。具体例としましては、宿泊料金表示を即時ポイント利用での割引後の料金表示に切り替えるられる機能を追加したり、フォトギャラリーのサムネイル表示の追加などを行いました。

f:id:kawamuram:20181219114156p:plain

必要なスキルの違い

意匠力

Webデザイナーの必要なスキルは「意匠力」です。施設の魅力をユーザーに届けるページ作り、時としてインパクトを残せるページ作りなどを行いますが、オシャレだったり、かっこいいといった見た目の印象、装飾的考案が大切です。

設計力

一方、UIデザイナーの必要なスキルは「設計力」です。ユーザーの目線に立ってユーザーが抱える問題の本質を考え、それらを解決するための設計をし、表現や形を作っていくことが重要です。

Webデザイナーは、多くの場合、施設側や営業担当から指示書があるので、何を見せたいかの意図をくみ取りデザインで表現する流れですが、UIデザイナーは、今何が問題で、こうやったら使いやすくなるのではと仮説を立てるなど、デザインをするまでの過程がとても重要になります。

仕事の進め方の違い

ビジュアル的なクオリティの追求を自分だけで行える

Webデザイナーは営業担当や施設との調整はありますが、作業の進行自体は一人で行います。納期内であればビジュアル的なクオリティを突き詰めることに時間を割くことができます。

共同作業ならではのルールがある

一方、UIデザイナーはマーケティングやエンジニアとの共同作業なので、作業の進行は自分だけの判断では進められません。また、エンジニアと認識合わせなどを文章に残すのも重要で、やりたいこと、なぜやるかなどを案件ごとに言語化します。

個人プレーからチームプレーとなったことで、自分のタイミングでリリースできないことにストレスを感じたり、言語化する作業が煩わしく感じたりすることもありました。 しかし、リリース前に必ずレビューが入ることでミスを防げたり、なぜやるかというのを文章で残すことで、自分自身も思考の整理ができたり、過去のリリースを振り返る際にも役立ってます。

まとめ

f:id:kawamuram:20181221092253p:plain

一休においての各デザイナーの違いについて、簡単にまとめてみました。あくまでも私が個人的に感じたことなので、一般的な「WebデザイナーとUIデザイナーの違い」とは異なるかもしれません。

目的別チームで各々作業している一休のデザイナーですが、「一休らしい綺麗なサイト」や「高級感を感じるデザイン」を作りたい、というのは共通して目指していることです。そのために私たちデザイナーは、デザインの力(意匠力、設計力)を駆使して、「美しく機能的なサイトで宿泊先を選んでいる」という、ユーザーの心地よい体験を叶えるべく、改善を繰り返します。

一休では、ともに良いサービスをつくっていく仲間(デザイナー/エンジニア/マーケティング)を積極募集中です。応募前にカジュアルに面談をすることも可能ですので、お気軽にご連絡ください。

明日は @hayatoise さんによる「読書合宿のお話」です。お楽しみに!

Hangfireで実現する.NETアプリのバックグランドジョブ

この記事は一休.comアドベントカレンダー2018の19日目です。

qiita.com


ある程度の規模のウェブアプリケーションであれば、応答性能を損なうことなく複雑な業務処理を完遂させたい場面が出てきます。 このような場合、処理をある程度の粒度で切り出して、応答を返すプロセスとは別のプロセスで処理する、という方法が考えられます。 例えば、予約完了処理の中でメール送信部分だけを別のプロセスで処理する、といった具合です。 ASP.NETでこのようなバックグランドジョブ処理を実現するには、どのような方法があり得るでしょうか。

この記事では、.NET環境で利用できるバックグラウンドジョブのライブラリであるHangfireについて、当社での利用事例を簡単に紹介します。

Hangfireを導入した経緯

当社のサービスにも当然、上述したようなバックグラウンドジョブのニーズがありました。

ASP.NETでバックグラウンドジョブの処理を実現する場合、公式に提供されているQueueBackgroundWorkItemを使うという手段があります。 これなら導入は非常に簡便です。しかし、大事な処理をバックグラウンドジョブで実行することを考えると、ジョブのステータス(キューイング、処理中、処理正常完了、処理失敗)やジョブの実行履歴の管理も行いたい、と考えたました。
この場合、QueueBackgroundWorkItemを採用するなら、その部分は自前で作らなければなりません。
また、クラウドが提供するキュー処理のサービスを使う方法もあり得ますが、この場合も、ジョブのステータスやジョブの実行履歴の管理は自前で作らなければなりません。 Hangfireは、ジョブストレージとジョブの管理機構が組み込まれており、かつ、既存の.NETのコードを大きく変えることなく利用できるという利点があります。 作者によれば

Hangfire is a .NET Framework alternative to Resque, Sidekiq, delayed_job, Celery.

だそうです。 以上の点を総合的に加味して、Hangfireを活用することに決めました。

構成

f:id:s-tokutake:20181219123844p:plain

Hangfireを使う場合、アプリケーションの構成は大きく以下のふたつがあり得ます。

  • バックグラウンドジョブ処理を行いたいウェブアプリケーションの内部にバックグラウンドジョブサーバを動作させる。
  • バックグラウンドジョブ処理を行いたいウェブアプリケーションとバックグラウンドジョブサーバを完全に分離する。

当社では、完全に分離する構成にしました。内部にバックグラウンドジョブサーバを動作させる場合、ウェブアプリのプロセスの再起動とジョブの処理の状態を気にする必要があるため、完全に分離したほうが運用が簡単だと判断しました。

上記の図の通り、ジョブキューのストレージにはElasticache Redisを使っています。Hangfireは標準ストレージとしてSQL Serverを使いますが、Redisのほうがより高性能であるというベンチマーク結果を考慮し、Redisにしました。

また、ジョブのステータスや各種管理ができるDashboardは、Hangfireを使うウェブアプリともバックグラウンドジョブサーバとも別のサーバに構築しました。

実装例

実装自体はとても簡単です。

namespace Sample.Service.JobQueue
{

    /// <summary>
    /// バックグラウンドジョブ導入のためのサンプルクラス
    /// </summary>
    public class JobQueueSampleService 
    {

        public void DoSomething(string param1)
        { 
            JobQueueClient.Enqueue(() => DoAsBackgroundJob(param1));  
        }

        /// <summary>
        /// このメソッドをキューに詰める。
        /// </summary>
        /// <param name="param1">
        /// Hangfireは、パラメータをJsonでシリアライズしてストレージに詰める。
        /// </param>
        [Hangfire.AutomaticRetry(Attempts = 3)] // <== リトライ回数をキューに詰めるメソッドの属性で回数を指定する。指定しないと10回リトライ。リトライしないなら0回にしておく。
        public void DoAsBackgroundJob(string param1)
        {
             // --- do something  
        }
    }

    public class JobQueueClient
    {

        private static readonly Lazy<IBackgroundJobClient> _cachedClient = new Lazy<IBackgroundJobClient>(() => new BackgroundJobClient());

        public static string Enqueue(Expression<Action> methodCall)
        {
            var client = _cachedClient.Value;

            return client.Create(methodCall, new EnqueuedState(QueueName));
        }
    }
}

Hangfireは、メソッドをジョブキューにエンキューする、という仕組みになっています。↑のコードでは、 JobQueueClient.Enqueue(() => DoAsBackgroundJob(param1)); で、 DoAsBackgroundJobメソッドでジョブとしてジョブストレージに保存しています。
エンキューすることで、メソッドのシグネチャやパラメータ、そのメソッドが属するアセンブリ名などがジョブストレージに保存されます。バックグラウンドジョブサーバは、この情報をデキューして、リフレクションを使って、保存されたメソッドを呼び出します。
リフレクションを使うので、Hangfireのクライアントとなるウェブアプリとバックグラウンドジョブサーバは同じアセンブリを参照する必要があります。

※Hangfireには、 BackgroundJob.Enqueue というエンキュー処理のためのわかりやすいインターフェースがあるのですが、これは後述する理由により、使いませんでした。

工夫点

前述した通り、Hangfireのクライアントとなるウェブアプリとバックグラウンドジョブサーバは同じアセンブリを参照する必要があります。 実運用でこれを実現しようとすると、次の2点を考える必要があります。

  • 毎日複数回デプロイされるウェブアプリケーションとバックグラウンドジョブサーバのアセンブリを一致させる必要がある。
  • ウェブアプリケーションのデプロイサイクルよりもLong Runningなジョブでもきちんと処理を完遂させる必要がある。
    • それも、エンキューしたウェブアプリケーションと同じアセンブリを参照しているバックグラウンドジョブサーバで処理を完遂させる必要がある。

このふたつを実現する実現するために、以下のふたつの手段を採用しました。

  • Hangfireの名前付きキューを利用する。
  • バックグラウンドジョブサーバはWindows Serviceとして稼働させ、同じアセンブリを参照しているウェブアプリケーションが古いバージョンになっても動かし続ける。

Hangfireの名前付きキューを利用する。

Hangfireは、キューに名前を付けることができます。ある名前のキューに詰められたジョブはその名前のキューを処理するように指定されたバックグラウンドジョブサーバでしか処理されません。キュー名は通常、次のようにキューに詰めるメソッドの属性で指定します。

[Queue("testqueue")]
public void SomeMethod() { }

そして、バックグラウンドジョブサーバ側ではサーバを初期化するときに、キューの名前を指定します。以下のように。

var options = new BackgroundJobServerOptions
{
    Queues = new[] { "testqueue" }
};

app.UseHangfireServer(options);
// or
using (new BackgroundJobServer(options)) { /* ... */ }

これで、testqueue というキューに詰めたメソッドは、↑のサーバでしか処理されなくなります。

さて、あるバージョンのアセンブリを参照しているウェブサーバがキューに詰めたメソッドは同じバージョンのアセンブリを参照しているバックグラウンドジョブサーバで処理させたい という要件は、以下を実現すれば満たせそうです。

  • 同じバージョンのアセンブリを参照しているウェブサーバとバックグラウンドジョブサーバは同じキュー名を利用する。そのキュー名はビルド単位で生成され完全にユニークである。

ビルド単位で生成され完全にユニークな値として、ビルドバージョンの番号がありました。そこでこの番号をキュー名にすることでこの要件を満たすことができました。

Untitled (1).png

ただし、Hangfireのサンプルなどで一番よく見かける BackgroundJob.Enqueue では、キュー名をメソッドの属性としてしか指定できません。つまり、動的に設定できないのです。調べてみると、BackgroundJobClient クラスのCreateメソッドを直接使うことでキュー名を動的に指定できることがわかりましたので、以下のようなBackgroundJobClient をラップしたクラスを作りEnqueueメソッドを実装しました。

  public class JobQueueClient
    {

        private static readonly Lazy<IBackgroundJobClient> _cachedClient = new Lazy<IBackgroundJobClient>(() => new BackgroundJobClient());

        public static string Enqueue(Expression<Action> methodCall)
        {
            var client = _cachedClient.Value;

            return client.Create(methodCall, new EnqueuedState(QueueName));
        }
    }
}

バックグラウンドジョブサーバはWindows Serviceとして稼働させ動かし続ける。

新しいウェブアプリがデプロイされても古いバックグラウンドジョブサーバが動作していれば、デプロイサイクルよりもLong Runningなジョブもきちんと処理できます。 これは、シンプルに バックグラウンドジョブサーバはWindows Serviceとして実装し、新しいバージョンのバックグラウンドジョブサーバをデプロイをしても、古いバージョンのバックグラウンドジョブサーバは停止しないようにしました。 バックグラウンドジョブサーバをWindows Service として動かす方法は、公式サイトにも開設されています。しかし、このやり方には従わずに、Topshelfを使ってServiceにしました。Windows Serviceのインストール、アンインストール、開始、停止が簡単に制御できるためです。

バックグラウンドジョブサーバのデプロイはAWS Codedeployを使います。 Codedeployのafter installのhookで、バックグラウンドジョブサーバをWindows Serviceとしてインストールし、開始をしています。 このとき、配置するフォルダをデプロイバージョンごとに変えることで、既存のServiceを動かし続けながら、新しいバージョンのServiceを稼働させることができます。

appspec.ymlは↓のようにします。

version: 0.0
os: windows
files: 
  - source: /
    destination: d:/latest
hooks:
  AfterInstall:
    - location: /after-install.bat

after-install.batは、以下の通り。

rem $TARGET_FOLDER を ビルド時にビルドバージョンに書き換える。
xcopy D:\latest D:\$TARGET_FOLDER\ /E /Y /I /Q
D:\$TARGET_FOLDER\bin\BackgroundJob.ProcessingServer.exe install
D:\$TARGET_FOLDER\bin\BackgroundJob.ProcessingServer.exe start

ただし、このままだと永遠にWindows Serviceが増え続けてしまいます。対策として、日次で1日以上古くなったバージョンのバックグラウンドジョブサーバを停止するようにしました。

まとめ

当社ではHangfireをプロダクション環境の業務処理で活用し始めてまだ日が浅いです。しかし、現時点では、問題なく動作しています。 今後は、短い間隔で動かしているバッチ処理をウェブアプリのバックグラウンドジョブに切り替えていく、という使い方も想定しています。

この記事の筆者について

  • システム本部CTO室所属の 徳武 です。
  • サービスの技術基盤の開発運用、宿泊サービスの開発支援を行なっています。