オウチーノ開発者ブログ

「株式会社オウチーノ」の社員によるブログです。

Rails+ReactなSPAサイトでSEOをしようとしてぶつかった壁

Rejectcon 2018 レポート記事

こんにちは。オウチーノ吉川です。

去る9/29にRejectconで「Rails+ReactなSPAサイトでのSEO」について登壇してきました。 SPAサイトでSEOを行う際にぶつかった壁と、それをどのように乗り越えていったかの経緯をご紹介しました。

ちょっと内容詰め込みすぎて途中途中端折ってしまったのと、発表後にもいろいろ質問などをいただいていたのであわせて補足しながらご紹介します。

発表資料はこちらです。

なぜ経緯を紹介したのか?

昨今のフロントエンド技術の進化速度は非常に速く、どんどん新しい技術が登場しています。 記事などでどういった技術が登場しているのかは抑えているが、実際に全てちゃんと触ってはいないという方も多いのではないでしょうか。

SSRをするためにはreact-railsやhypernovaがある、ということを知っているだけでは、いざ使うとなったときにどちらを導入すべきか判断できません。 Dynamic Importsについても、利用ケースを知らなければなぜこんな面倒なことをするのか、という印象を持つかもしれません。

今回の対応を進めるにあたって、対応を進めるに連れて昔読んだ記事にたどり着いて、最初からヒント読んでたのに!となったことが何度もありました。 そのため背景をお伝えすることが重要だと考えました。

React-HeadによるSSR

時間がなくて端折ったところです。

React-HeadはSSR対応head管理ツールです。コンポーネントとしてmetaタグなどを設定しておけば、head部分に適切にタグを挿入してくれます。 と、CSRの場合はそれで良いのですが、SSRの場合は全体のレイアウトを別途持っているはずで、その適切な場所に入れ込む必要があります。

React-Headのアプローチは、HeadProvider にArrayを渡しておけば、コンポーネントとしてセットされたmetaタグなどを集めてArrayに入れておいてくれます。そのArrayのみを別途renderToString して差し込んでやるというアプローチです。 これ自体もなかなか複雑なアプローチですが、あくまでNode.jsを想定したアプローチで、hypernovaの場合はそのmetaタグコンポーネントを保持しているのがhypernova、テンプレートはRails側にあるためプロセスをまたいで渡す必要が出てきます。 そこでhypernovaがレンダリングしたルートコンポーネント部分のhtmlにカスタムタグとしてくっつけてRailsに返し、Rails側で文字列操作で適切な場所に入れ替えるという力技で対応しています。

結局SSRはSEOのために必要だったの?

比較検証ができていないので断言はできませんが、SEO上は必須ではないと考えています。 オウチーノの事例ではSSRを導入してもインデックス状況は改善せず、パフォーマンス改善によってインデックス状況が改善しました。 またSSRしないと駄目だというならそもそも全くインデックスされないはずです。 現状もパフォーマンス改善のためにSSRを活用していますが、これはSSR導入済みだったためそれを活用しただけで、必須だったわけではありません。

SSRにすることでのメリットもありますが、メンテナンスコストは高めです。 一番のコストはSSRが壊れないようにすることです。Universal JSでない実装をしてしまって壊すことは日常的にありえますが、 SSRに失敗していてもCSRになるだけなので手元で開発していても気づかない場合があります。 現状はE2Eテストを使ってリリース前になるべく気づけるようにしてはいますが完璧というわけではありません。 またオウチーノの場合はhypernovaを利用しているため、hypernovaサーバーを別途運用する必要があります。 Docker化していてECSで運用しているためすごく手間というわけではありませんが・・・

RailsでSSRするのに疲弊しているように見えるけれど、Railsにのせる必要はあるの?

技術的な側面から言えば必要ではありません。エコシステムとの親和性を考えると、サーバー側はRailsではなくNode.jsがやりやすそうではあります。

一方でオウチーノは現在「全体をモノリスにする」という方針で進めています。 このあたりの背景については以前の記事をご参照ください。 https://developers.o-uccino.com/entry/2018/08/30/152838

今後チームが成熟するにつれてSPAは独立させることも検討しています。

sagaやReact-Headの部分は、あらかじめRails側でpropsを作って渡す形にすればシンプルになるのでは?

最初からSSRを想定した設計にするならそういったアプローチも可能だと思います。 SSRを想定しておらず、routingもすべてReact側にやらせている設計だったのと、SSRにすることで問題が解決するかどうかわからない状況下で 大きく設計を変更すべきでないと判断しこういった対応になりました。

これからそういった設計に変えることもできるのですが、どちらかというとSSRしなくて良い状態にできればベターと考えています。

E2Eテストはどうやってやっている?

変更がマージされた後まずRSpecやJestなどによる各種テストを行っています。 RSpecのfeature specとしてCapybara + Headless Chrome によるテストを行っています。 ただしこの段階ではhypernovaサーバーは使っておらずCSRによるテストです。

テストが通った後asset compileやdocker buildを経てstagingに自動デプロイされます。 stagingへのデプロイ後にE2Eテストをstagingに対して実行しています。 こちらはCapybara + PhantomJSを利用しています。

メジャーシナリオに対して期待した操作ができることに加え、SSRが実行された上でのhtmlが返却されていることや、意図通りのメタタグが出力されていることなどをテストしています。 このE2Eテストが通ったdocker imageに対してタグを付与しており、productionへはこのタグが付与されているものしかデプロイできないようになっています。

なおここでPhantomJSを利用しているのは苦肉の策です・・・ 以前Chromeでは問題ないのにSearch Console上ページがレンダリングされないというエラーが発生したことがあり、 PhantomJSだけがそのエラーを再現できたためこういった構成になっています。 どうやらGoogleのクローラーが利用しているChromeは最新というわけではないらしく、最新のHeadless Chromeを利用していると気づけないケースでした。 ただし現在PhantomJSの開発は停止しているため、Headless Chromeに戻すべきかどうかは検討中です。

このあたりのビルドパイプラインまわりはまた改めて記事にできればと思います。

最後に

いかがだったでしょうか。今後もオウチーノではユーザーにより使いやすいサービスを提供するために技術的なチャレンジを進めていきたいと考えています。 オウチーノでは一緒に働くメンバーを募集中です

GrafanaとPrometheusとTwilioを使ってサーバー監視システムを構築する

オウチーノのSREチームの尾形です。

今回はオウチーノのサーバー監視の仕組みについてご紹介したいと思います。

経営方針が変わる前のオウチーノのサーバー監視は全て外部会社にお願いしていまして、その時は個別の依頼ベースで監視対象を追加・削除してもらっていました。自分達で管理していない状態なので、監視項目に漏れがあったり柔軟な設定が出来ない、Opsへの認識が薄くなるなどの問題がありました。そこで自分達のシステムは自分達で面倒を見ようということで去年から監視をする仕組みを構築し始めました。外部会社ではZabbixを利用していたので、その設定をそのまま頂くことも出来たのですが秘伝のタレを頂いても管理が出来ないということでゼロから構築することを決めました。
SREチーム以外にも見てもらえるグラフを表示出来るツールということでGrafana+Prometheusで構築しました。オウチーノではAWSを利用しているため、AWS CloudWatchで取得できる情報はAWS CloudWatchに任せたりしています。

Grafana + Prometheusの構築方法については今回割愛します。

監視の仕組み

GrafanaにDashboardを表示し、オウチーノドメインのGoogleアカウントを持っている人なら誰でも見ることの出来る状態にしています

CloudWatchMetrics

こんな感じにDashboardを用意し、開発者に気軽に見てもらえるようにしています。もちろん開発者がDashboardやグラフを作ることも可能です。
各グラフにアラートの閾値を設定し、閾値を超えたものに関してグラフ付きでSlackに通知します。

SlackNotification

PrometheusではEC2上に乗っているMySQLのメトリクスやヘルスチェック、EC2のCPUやメモリなど様々な値を収集しています。
現在はまだ出来ていませんが、コンテナ単位のメトリクス取得もする予定です。

Prometheusのデータ収集は各種Exporterを利用しています。監視をOFFにしたい時もあるので、その場合はEC2のTagで制御しています。
prometheus.ymlに以下のような設定値を追加し、PrometheusNodeExporterタグがenabledのものだけを収集するようにしています。

  - job_name: ec2-node-exporter
    ec2_sd_configs:
      - region: ap-northeast-1
        refresh_interval: 60s
        port: 9100
    relabel_configs:
      - source_labels: [__meta_ec2_tag_PrometheusNodeExporter]
        regex: (enabled)
        action: keep
      - source_labels: [__meta_ec2_tag_Name]
        target_label: name

サーバーの追加

Itamaeを利用してサーバーを構築しています。
(ItamaeはChefを簡潔に記載出来るように作られたOSSです。)
共通化したレシピを用意してあるので、各サーバーのレシピを書く際に単純にincludeすればexporterが入ります。

e.g.

include_recipe "../../cookbooks/prometheus-ec2-exporter/default.rb" # この1行を追加するだけ
include_recipe "../../cookbooks/timezone/default.rb"
include_recipe "../../cookbooks/hostname/default.rb"

execute "apt-get update"

...

構築後expoterが起動すると設定に則りメトリクスが収集されます。共通化されたレシピを利用している場合は既にGrafana上にグラフがあるので、特別な設定をしなくてもグラフ上に構築したサーバーが表示され、アラート監視の管理下に入ります。
ItamaeのレシピはGit管理下にあるため、GitHubのPR時のレビューで監視の漏れがないかをチェック出来、1度レシピを作ればSREチームでなくとも簡単にサーバーを構築・監視下に置くことが可能です。

オンコールの仕組み

Slack通知だけでは夜間・休日に気づくことが難しくなります。なのでSlack通知の他に電話通知もしています。
電話発信にはTwilioという音声通話をAPIでコントロールできるサービスを利用し実現しています。
大まかな流れは以下のようになります。

  • Googleカレンダーに予め第1窓口、第2窓口の人を週替わりで登録しておく
  • Prometheusがアラートを検知
  • Googleカレンダーに登録されている第1窓口の人に電話
    • 電話を受けた第1窓口の人はチャットボットに対応しますコマンドを投げる
  • 約10分第1窓口の人から応答がなければ第2窓口の人に電話

ここからは実現方法を簡単に説明していきたいと思います。

Googleカレンダーに予め第1窓口、第2窓口の人を週替わりで登録しておく

Google Spread Sheetsにマスターを登録して、日々スクリプトでカレンダーを更新しています。
ここではオンコールだけでなく、トイラーも登録しています。トイラーについては前回のブログのCTOのスライドをご参照ください。

OncallMasterSheets OncallCalendar

スケジュールを更新するスクリプトはGoogle Apps Scriptを利用していて、Google Apps Scriptのスケジューラ機能で日々Google Spread Sheetsのマスターベースに最新のカレンダーに更新します。順番を変更したい場合はGoogle Spread Sheetsをいじってもらえれば次の日には次週以降の情報が更新されます。
(スクリプトの内容については目を瞑って頂けると٩( ᐛ )و

var spreadSheetUrl = 'https://docs.google.com/spreadsheets/xxxx';
var calendarId = 'XXXX'

var columnDefinitions = { 'オンコール': 1, 'トイラー': 2 }

function users(target) {
  var column = columnDefinitions[target];
  var spreadSheet = SpreadsheetApp.openByUrl(this.spreadSheetUrl);
  var sheets = spreadSheet.getSheets();
  var sheet = spreadSheet.getSheetByName('シート1')
  
  var row = 2; // 1行目は列タイトルなので2行目から
  
  var targetUsers = [];
  while(true) {
    var user = sheet.getRange(row, column).getValue();
    if(user == "") {
      break
    }
    targetUsers.push(user);
    row++;
  }
  return targetUsers;
}

function previousMonday() {
  var today = new Date();
  return new Date(today.getFullYear(), today.getMonth(), today.getDate() - today.getDay() + 1);
}

function calenderEvents(seachQuery) {
  var startTime = previousMonday(); // 月曜始まり
  var endTime = new Date(startTime.getFullYear(), startTime.getMonth() + 4, startTime.getDate());
  return CalendarApp.getCalendarById(calendarId).getEvents(startTime, endTime, {search: seachQuery});
}

function removeAfterNextWeekEvents(target) {
  var events = calenderEvents(target);
  if(events.lentgh == 0) {
    return;
  }
  events.slice(1, events.length).forEach(function(element) {
    element.deleteEvent();
  });
}

function addCalender(target) {
  var oncaller = users(target);
  var primaries = oncaller.slice();
  var secondaries = oncaller.slice();
  var findIndex = 0;
  var endTime = previousMonday(); // 月曜始まり
  
  var events = calenderEvents(target);
  if(events.length != 0) {
    var lastEvent = events.pop();
    findIndex = oncaller.indexOf(lastEvent.getTitle().match(/Primary: (.*), Secondary: (.*)/)[2]);
    endTime = lastEvent.getEndTime();
  }
  
  // rotate
  for(var i = 0; i < oncaller.length; i++) {
    if(i < findIndex) {
      primaries.push(primaries.shift());
    }
    if(i < findIndex + 1) {
      secondaries.push(secondaries.shift());
    }
  }
  var calendar = CalendarApp.getCalendarById(calendarId);
  
  for(var i = 0; i < primaries.length; i++) {
    calendar.createEvent('【' + target +  '】 Primary: ' + primaries[i] + ', Secondary: ' + secondaries[i],
                    new Date(endTime.getFullYear(), endTime.getMonth(), endTime.getDate() + 7 * i),
      new Date(endTime.getFullYear(), endTime.getMonth(), endTime.getDate() + 7 * (i + 1)));
  }
}

function upsertOncaller() {
  removeAfterNextWeekEvents('オンコール');
  addCalender('オンコール');
}

function upsertToiler() {
  removeAfterNextWeekEvents('トイラー');
  addCalender('トイラー');
}

function upsert() {
  upsertOncaller();
  upsertToiler();
}

Prometheusがアラートを検知

Prometheusにアラートを設定して、アラート発生時のアクションにWebhookを登録することで電話通知サービス(Sinatra製サービス)をキックします。
これらの設定はGrafanaのGUIから設定しています。

http://docs.grafana.org/alerting/notifications/ NewNotificationChannel

http://docs.grafana.org/alerting/rules/#notifications SetAlertNotification

Googleカレンダーに登録されている第1窓口の人に電話

キックされた電話通知サービスがGoogleカレンダーから窓口の人の名前を取得しに行き、名前から電話番号を引いてTwilioのAPIをコールします。
電話発信をした後、第1窓口の人が未対応という状態を保存します。

Googleカレンダーの取得

GoogleカレンダーをAPIで取得するにはOAuth2の認証が必要なので、電話通知サービスのデプロイ時に手動で認可しています。
(ここが自動化のネックになっているのでどうにかしたい・・・ナイスな案をお持ちの方は是非教えてください!)
アラートが全く鳴らない幸せな世界が続いた場合にリフレッシュトークンが切れるので何かしらのリカバリー手段が必要なのですが、幸い(?)アラートは少なくとも週1で発生しているのでまだ問題に直面していません _:(´ཀ`」 ∠):...

電話の発信

TwilioのAPIをコールしています。

TwilioのAPIは公式ドキュメントが充実しているので、そちらを参照して頂くのが良いと思います。
SDKのサポートしている言語の種類も豊富で困ることは少ないと思います(201809時点ではGoが非サポートです)。

公式の電話発信のRubyサンプルは以下のようになっています。

# Get twilio-ruby from twilio.com/docs/ruby/install
require 'twilio-ruby'

# Get your Account SID and Auth Token from twilio.com/console
account_sid = 'ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
auth_token = 'your_auth_token'

# Initialize Twilio Client
@client = Twilio::REST::Client.new(account_sid, auth_token)

@call = @client.calls.create(
  url: 'http://demo.twilio.com/docs/voice.xml',
  to: '+14155551212',
  from: '+15017122661'
)

puts @call.sid

オウチーノでは電話でアラートに気づけばいいよねということでサンプルボイスをそのまま流していますが、自前の音声ファイルを流したりテキストを読ませるということもTwilioではサポートしています。電話に出るのが楽しくなるようなアイデアを募集中です!

状態の保存

サービスのサーバのローカルファイルに状態を記載しています。
DBなどを利用するのがいいのでしょうが、手抜きしました v^^v

電話を受けた第1窓口はチャットボットに対応しますコマンドを投げる

チャットボット経由で未対応状態を解消します。

e.g. @ruboty ack

これはチャットボットがack(acknowledge)コマンドを受けたら電話通知サービスに対して状態削除APIをコールしています。
これは発信したユーザーを特に見ていないので、アラートに気づいて対応できる人がいたら代わりに投げてくれたりします。

ちなみにオウチーノではチャットボットにrubotyを利用しています。

約10分第1窓口の人から応答がなければ第2窓口の人に電話

cronで定期的に状態を確認し、アラートが発生してから10分間応答がない場合に第2窓口の人に電話します。
電話をかける方法は第1窓口の人に電話をした時と同じです。

今後の課題

途中にも書きましたがGoogleのAPIを呼ぶ周りをもう少し整理したいのと、現状アラートアクションのトリガーとなっているPrometheusと電話発信をするサービスの冗長性が皆無なので、どちらかが死ぬと悲劇が起きるため、何かしら対策を打ちたいと考えています。

最後に

モニタリングにオーナーシップを持つようになった結果、トラブルの予兆を見つけて事前に対策したり、リソースが余っている場合にはスケールダウンしてコスト削減したりといったことが柔軟にできるようになりました。
とはいえ手動な部分が多く残っているので、これからも改善を続けていきたいと思います。

オウチーノでは一緒に働くメンバーを募集中です

漸進的なシステムリプレイス(STTMeetup システムリプレイスNight)

オウチーノの吉川です。この度オウチーノでも開発者ブログをはじめます。 これからオウチーノの技術的な取り組みやサービス開発の裏側についてご紹介していきます。 どうぞよろしくお願いします。

システムリプレイスNight

先日STTMeetup#7 システムリプレイスNightが開催されました。 STTMeetupはスタートトゥデイテクノロジーズさんが定期的に開催しているミートアップで、今回はシステムリプレイスがテーマです。 スタートトゥデイテクノロジーズさんもちょうどZOZOTOWNのシステムリプレイスに取り組んでいるということで、 各社のシステムリプレイスに関する取り組みについて私と弊社山本がご紹介しました。

オウチーノのリニューアル

オウチーノでは昨年2017年に経営体制が変わり、この1年で開発体制も大きく変化しています。 システムのリニューアルや新サービスのローンチも着々と進んでおり、今月にはオウチーノのポータルサイトの中核である新築・中古サイトもリニューアルされました。

オウチーノはもともと新築、中古、賃貸サイトなどをそれぞれ独立したサービスとしてローンチし、あとから統合してきた経緯があります。 またもともと外注中心の体制だったため、担当業者によって技術スタックが異なっており、ColdFusion+OracleなものあればCakePHP+MySQLなものやStruts2もあったりとばらばらでした。それが今やReact+Railsのアプリケーションに集約されつつあります。

と、言葉にするのは簡単ですがもちろん容易なことではありません。システムのリプレイスはともすれば泥沼化しがちで、頓挫することもままあります。 そうならないようにどのような工夫をしてきたかについてSTTMeetupでお話しました。

分かれたシステムをていねいにモノリスに集約する

私からはリプレイスを進めるにあたっての方針策定やアーキテクチャ、組織的なとりくみについてご紹介しました。 タイトルだけ見るとMicroservicesつらいからモノリスにしたとか、分断されたモノリスの話を思い浮かべるかもしれませんが、そういった話ではありません。 ちなみにもとの状態を一言でいうなら分断されたモノリスが乱立した状態でした。

例えばPC版はColdFusionでスマートフォン版がPHPで同じOracleを見ていたり、バッチが何系統も別にあって、あるバッチが別のバッチの結果ファイルを監視してドミノ倒しで処理していくようなイメージです。

それ自体も良い状態ではないのですが、あれもこれも解決しようとしはじめるのは泥沼化への近道です。 オウチーノの場合はシステムの分断以上に開発者が分断されてしまっていたことが問題と考え、それを解決する手段としてモノリスを選択しました。

リプレイスではなくリノベーションという選択について

私からは新モノリスアプリケーションを中心お話しましたが、それを実現するための裏側として新システムと旧システムの間の構成について山本が発表しました。 新システムと旧システムの間をうまくつなげることで新システム側が開発しやすくなるだけではなく、新旧同じツールでデプロイやログ解析ができるようになりました。完全置き換えしなくてもちょっとした工夫でディレクターの待ちとエンジニアの作業を減らすことができ、ひいてはリプレイスがより進みやすくなります。 ちなみにタイトルはなんとかして不動産用語を使いたかったようです。

当日の様子

オフィスがものすごくおしゃれでケータリングも豪華でした。

f:id:adorechic:20180828194258j:plainf:id:adorechic:20180828195625j:plainf:id:adorechic:20180828201425j:plain

スタートトゥデイテクノロジーズのみなさんにこの場を借りて感謝いたします。 どうもありがとうございました。

最後に

これからのオウチーノを一緒に作ってくれるメンバーを募集中です。 少しでもご興味を持っていただけたならぜひご連絡ください。