tech.kayac.com

Lobiチームの長田です。 今回はLobiの根幹であるチャットサービスの、Streaming APIについて紹介します。

多くのチャットサービスがそうであるように、 Lobiでも新しいチャットメッセージが画面リロードの必要なく表示されるようになっています。 チャットメッセージをデータストリームとしてクライアントに送信するためのAPIがStreaming APIです。 LobiのチャットサービスはiOS・Android・Webブラウザで利用することができ、 これら全てでStreaming APIを使ったチャット画面の自動更新を実現しています。

即時反映の実装方法としては

  • Polling
  • Long Polling
  • Web Socket

など複数の方法が挙げられますが、LobiではHTTPリクエストのLong Pollingをベースにした 独自のフォーマットを使用しています。

仕組み

sequence.png

  • app - メインのアプリケーション。メッセージ送信依頼を出す
  • publisher - job queue。appから来た送信依頼をnockに渡す
  • nock - メッセージ送信アプリケーション。クライアントの接続を受け付け送信依頼を元にメッセージを流す

少々複雑に見えますが、「認証」「メッセージ送信」「終了」の3つに分けて考えるとシンプルです。 一度認証すると一定時間(Lobiの場合は600秒)クライアントとの接続を維持し続け、 その間に投稿されたチャットメッセージを対応するクライアントに送信します。 クライアントが切断するかタイムアウトすると接続を終了します。

使用言語についてですが、 appはLobiの主要言語であるPerl、publisherとnockはNode.jsで書かれています。 nockをPerlではなくNode.jsを採用した理由としては、 大量のクライアントからの接続を少ないプロセスで維持したかった、ということが挙げられます。 同様の実装をPerlで行なった場合、接続ごとにforkしてプロセスが大量に作られるか、 少しクセのあるAnyEventを使って実装を行うことになるでしょう。 (余談ですが、いま再実装するならGoを選ぶはずです) publisherがNode.jsなのは、こちらも言語の特徴を活かすため・・・というわけではなくnockとセットで作られたからです:p

クライアントの管理

nockはクライアントとの接続を維持するためのアプリケーションです。

クライアントから新規接続が発生した場合、nockからユーザー情報を持っているappに認証リクエストが行われます。 この際にappでは、「対象のチャットグループをキーとした接続先nockのホスト名」をKVSに保存します。 KVSはLobiサービス内のどこからでも参照でき、異なるホスト上のappからでも同じ値が参照できます。

認証が完了すると、nockは「対象のチャットグループをキーとしたクライアントの情報」をメモリ上に保持します。 メモリ上に保存するため、この値はクライアントとの接続を維持しているnockしか知ることはできません。 この情報はクライアントとの接続が終了した際に破棄されます。

このように、アプリケーションごとに必要な情報を絞ることによってアプリケーションごとの結びつき疎にしています。

メッセージの送信

新しいチャットメッセージが投稿されると、appがpublisherにメッセージ送信リクエストを行います。 送信リクエストには、

  • チャットメッセージの内容
  • 対象nockのホスト名

が含まれています。

publisherはjob-queueの仕組みを持っており、送信リクエストをキューに追加すると即座にappへレスポンスを返します。 appから直接nockに送信リクエストを行なった場合、nockがbusyだった場合に待ち時間が発生してしまいます。 この待ち時間を最小にするための緩衝材としてpublisherが用意されています。

publisherは受け取ったリクエストを順に処理し、そこに含まれるnockのホスト名を元にnockへのメッセージ送信リクエストを行います。 どのnockホストにリクエストを送ればいいのかがわかっているので、必要充分なリクエストのみが発生します。

クライアントに送信する内容

HTTPを使ったLong Pollingを行っているわけですが、その間nockからclientに送られるデータは例えば以下のようになっています。


$ curl -v 'https://stream.lobi.co/1/group/gggggggg?token=tttttttt'
*   Trying 52.68.212.118...
* Connected to stream.lobi.co (52.68.212.118) port 443 (#0)
* TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
* Server certificate: *.lobi.co
* Server certificate: Cybertrust Japan Public CA G3
* Server certificate: Baltimore CyberTrust Root
> GET /1/group/gggggggg?token=tttttttt HTTP/1.1
> Host: stream.lobi.co
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: openresty
< Date: Wed, 21 Sep 2016 06:50:49 GMT
< Content-Type: multipart/mixed; boundary="xHchms"
< Transfer-Encoding: chunked
< Connection: keep-alive
< X-Powered-By: Express
< X-Content-Type-Options: nosniff
<--xHchms
Content-Type: application/json

{"now":"1474440595.98001","chat":{"likes_count":0,"bookmarks_count":0,"boos_count":0,"created_date":1474440595,"urls":[],"message":"\u308f\u3044\u308f\u3044","image":null,"assets":[],"max_edit_limit_date":1474444195,"reply_to":null,"user":{"icon":"https://assets.nakamap.com/img/user/iiiiiiii_72.png","cover":"https://assets.nakamap.com/img/user/cccccccc_640.png","uid":"uuuuuuuu","premium":0,"name":"handlename","default":1,"description":"\ue408"},"type":"normal","id":"00000000","assets_expired":0,"edited_date":null},"event":"chat"}
--xHchms
Content-Type: application/json

{"now":"1474440629.42829","chat":{"boos_count":0,"image_type":"stamp","urls":[],"stamp_id":"661","image_height":240,"user":{"icon":"https://assets.nakamap.com/img/user/iiiiiiii_72.png","cover":"https://assets.nakamap.com/img/user/cccccccc_640.png","uid":"uuuuuuuu","premium":0,"name":"handlename","default":1,"description":"\ue408"},"id":"00000000","assets_expired":0,"likes_count":0,"bookmarks_count":0,"image_width":240,"created_date":1474440629,"message":null,"image":"https://assets.nakamap.com/static/stamps/lobi_aisatsu_2_achieve_08/01_w240/stamp_08.png?hash=20dc3d1f3ed78767a9e767854e6ad79d42240434","assets":[],"max_edit_limit_date":null,"reply_to":null,"type":"normal","edited_date":null},"event":"chat"}
--xHchms

ヘッダーを診てもらうと分かる通り、multipart/mixedを使って1回の接続に複数のメッセージを含めています。 新しいチャットメッセージが届くたびにboundaryで区切られたメッセージデータが送られてきます。 クライアントではこのメッセージデータを元に画面の更新を行っています。

Amazon Web Services固有の話

LobiはAmazon Web Services(AWS)上にサービスを構築しています。 Streaming APIの仕組みを提供する上で固有のトピックがあったので紹介します。

Auto Scaling時の再接続ラッシュ

Lobiで使用しているElastic Computing Cloud(EC2)のインスタンスのうち、 負荷増加の可能性があるものはAuto Scaling Groupによって自動的に増減するようになっています。

Streaming APIを提供するホストについても同様にAuto scaleしているのですが、 「長期間接続を維持する」という性質上、decreseする際に接続中のクライアントを同時に切断してしまうと、 その数だけ再接続が発生しnockの負荷が増加することになります。

2014年3月に行われたELBのアップデートでコネクションが切断されるまで接続を維持することができるように成りました。

Amazon Web Services ブログ: 【AWS発表】ELBのConnection Draining - インスタンスをサービスから注意深く取り除く

Streaming APIの最大接続時間が600秒なので、Connection DrainingのTimeoutにはこれよりも少し長い720秒を設定しています。 これにより再接続処理が集中することが避けることができるように成りました。

Elastic Load Balancerのコネクションタイムアウト

ロードバランサーに使用しているElastic Load Balancer(ELB)ですが、 LobiをAWSに移行した当初はコネクションのタイムアウトが60秒に設定されていました。 LobiのStreaming APIは最大600秒間接続を維持する仕様だったため、 当時のELB配下にはこのAPIを提供するホストを入れることができず、 DNSラウンドロビン方式で負荷分散を行なっていました。

2014年7月に行われたELBのアップデートでコネクションタイムアウトまでの時間が最大3600秒まで拡大されました。

Amazon Web Services ブログ: 【AWS発表】Elastic Load Balancingのコネクションタイムアウト管理

これにより他のホストと同じくELBによる負荷分散が可能になりました。

HTTP modeで切断が検知できない問題

ELBのコネクションタイムアウトが延長されたので、いざELB配下に! ということで接続テストを行なったところ、nginxがクライアントの切断を検知できないという問題があることがわかりました。 ELBがクライアントとのコネクションを終了しても、バックエンドのnginxには通知されず、 ELBとnginxのコネクションがタイムアウトまで残り続けてしまう、というものです。

これを回避するために、

  • ELBのListenerをHTTP modeではなくTCP modeに設定
  • これだけではクライアントのIPアドレスを記録できないため、Proxy Protocolを有効に

という対応を行いました。

おまけ

いまは使われていない昔話です。

Androidのプッシュ通知

Android端末へのプッシュ通知を実現するために、Googleが提供するC2DMというサービスを利用していました。 (下記のページを見てもらえば分かる通り、現在はDeprecatedです)

Deprecated | Cloud to Device Messaging (Deprecated) | Google Developers

しかし、C2DMには接続するクライアントごとにクオータが設定されており、それを超える通知を送ることができないという仕様の壁がありました。 チャットサービスで通知を送ることができないというのは致命的です。

この問題を解決するために、Android端末ではStreaming APIを使ってプッシュ通知を実現していました。 バックグラウンドで起動しているLobiアプリが常にStreaming APIへの接続を維持し続け、 サーバーサイドのアプリケーションはプッシュ通知が必要な場合にはStreamにメッセージを流すというわけです。

チャットグループのStreaming APIはアプリがフォアグラウンドにあるときだけ接続を維持すればいいのですが、 プッシュ通知を送るために使用する場合はアプリが起動していようがいまいが常に接続を維持しなければなりません。 アクティブなユーザーならまだしも、休眠ユーザーの接続まで維持しなければならず、 かといって休眠ユーザーの接続を破棄してしまうとプッシュ通知に寄る呼び戻し効果を捨てることになるというジレンマを抱えていました。

現在はGCMという新しいプッシュ通知送信の仕組みが提供されているため、 Streaming APIによる通知の仕組みは廃止しそちらを利用しています。

Cloud Messaging | Google Developers

おかげでStreaming API用のホストはGCM採用以前の半分ほどに減らすことができました。

おわり

次回はid発番ツールkatsubushiについて紹介します。

カヤックではリアルタイムメッセージングに興味のあるエンジニアも募集しています!

Lobiチームの長田です。 今回はConsulのLobiにおける活用事例を紹介します。

Consulとは

https://www.consul.io/

HashiCorp社が公開している、複数ホストを管理するために必要な機能が盛り込まれたツールです。

公式サイトには以下のように書かれています(意訳):

  • Service Discovery
    サービスの登録とDNSまたはHTTPインターフェイスを用いたサービスの特定。 SaaSのような外部サービスも登録することができる。
  • Health Checking
    オペレーターがクラスタ内で問題があったことを素早く知ることができる。 Service DiscoveryはHealth Checkingと連動し、 問題のあるホストがサービス特定の対象になることはない。
  • Key/Value Storage
    動的な設定・フラグなどをを保持するための柔軟なkey/valueストア。 シンプルなHTTP APIでクラスタ内のどこからでもアクセスすることができる。
  • Multi-Datacenter
    複雑な設定を行うこと無く複数のデータセンターをまたいで利用することができる。

最後のMulti-Datacenterについてはよほど大規模なクラスタを組まなければ利用することはないかもしれませんが、 それ以外の機能については数台のホストで構成されるクラスタを管理する際にも便利に利用できます。

各種情報を保持するconsul-serverと、その情報にアクセスするconsul-agentから成り、 実装にはGo言語が使用されています。

それではLobi内での具体的な活用事例を紹介していきましょう。

Lobi内での活用事例

chefによるconsul serviceの定義 (service)

Lobiでは各ホストのプロビジョニングにchefを使用しています。

consul serviceの定義はConsulの設定ディレクトリにchefでjsonファイルを配置することで行なっています。


node[:consul][:services].each do |sv|
  file "/etc/consul.d/#{sv['name']}.json" do
    owner "root"
    group "root"
    mode  0644
    content( { "service" => sv }.to_json )
    notifies :reload, "service[consul]"
  end
end

chefのroleやnodeごとに定義したservice(node[:consul][:services])の他に、 chefのroleや、


file "/etc/consul.d/role.json" do
  owner "root"
  group "root"
  mode  0644
  content( { "service" => { "name" => "role", "tags" => node[:roles] } }.to_json )
  notifies :reload, "service[consul]"
end

daemontoolsで起動しているサービス、


file "/etc/consul.d/daemontools.json" do
  owner "root"
  group "root"
  mode  0644
  content( { "service" => { "name" => "daemontools", "tags" => node[:daemontools_services] } }.to_json )
  notifies :reload, "service[consul]"
end

例えば、各種ログを集約するlog-aggregatorというchef roleが適用されているホストの場合、 log-aggregator.role.service.consul.という名前を解決する際の対象ホストとして登録され、 各ホストからはこの名前を指定することでいずれかのログ集約ホストにログを送信することができます。

他にも、

  • メール送信サーバー(postfix)
  • 内部用proxy(squid)

についても同様にconsul serviceに登録することで送信先の名前解決を行なっています。

各アプリケーションのセカンダリ参照 (service, dns)

Service Discoveryの機能を使い、クラスタ内における名前解決を行なっています。 dnsmasqを経由することで通常のDNSと同じように利用することができます。

Lobi内ではid発番にはkatsubushi、 iOSへのプッシュ通知送信にはgunfishを使用しています。 これらのアプリケーションはクラスタ内の各ホストで起動しており、 利用側はローカルホストを参照することでアプリケーションに接続します。

Consulにサービスとしてこれらのアプリケーションを登録することで、 ローカルホスト以外をセカンダリとして利用することができます。


# PerlモジュールのCache::Memcached::Fastを使用してkatsubushiに接続する場合
# ※katsubushiはmemcachedプロトコルを使って接続を受け付ける
my $client = [
    # 正常時はlocalhostで起動しているkatsubushiに接続
    Cache::Memcached::Fast->new({ servers => ["localhost"] }),
    Cache::Memcached::Fast->new({ servers => ["katsubushi.service.consul"] }),
];

# localhost -> katsubushi.service.consul の順でid取得を試みる
for my $client (@$client) {
    my $id = $client->get("id");
    return $id if $id;
}

デプロイイベントの通知 (event, watch)

Lobiではデプロイ操作をstretcherを用いて行なっていますが、 この起動をconsul event経由で行なっています。


deploy server --> consul event --> target hosts

daemontools serviceのrunスクリプトは以下のような物を使用しています。


# manifestのURI($uri_for_manifest)がeventの値として送られる
consul event -name deploy -node $target_nodes_regex $uri_for_manifest

daemontools serviceとしてconsul watchを実行しeventを受け取ります。 runスクリプトは以下のようになっています。


#!/bin/sh

set -e
exec 2>&1

umask 002

# consul watchでconsul eventの発火を待ち受け
# eventを受け取るとstretcherコマンドが実行される
exec setuidgid lobi consul watch -type event -name deploy stretcher

stretcherによるデプロイについて、詳しくは以下の記事を参照下さい。

ファイル更新もchef実行もstretcherで!Lobiをデプロイするときにやっていること \| tech.kayac.com - KAYAC engineers' blog

多重実行防止 (lock)

Lobiではスムーズな開発をサポートするため、 また一般ユーザー向けのイベントを開催するためなど、 様々な用途でchat botを動作させています。

botアプリケーションの冗長化については、 複数のホストで同一のbotアプリケーションを動作させることで行なっています。 HTTPリクエストを受け付けて動作するようなものであれば ロードバランサーがひとつのホストだけに振り分けるので問題ないのですが、 たとえばWebSocketでイベントを受け取る場合、 (イベント送信側の実装にもよりますが)複数のbotアプリケーションが同時にイベントを受け取ることになってしまいます。 この問題はbotアプリケーション間で連携することでも解決できますが、 小規模なbotアプリケーションの場合あまり複雑な仕組みは入れたくないものです。

そこでconsul lockを使って、 クラスタ内にひとつのbotアプリケーションしか起動しないようにしました。 このbotアプリケーションの起動コマンドは以下のようになっています。


exec consul lock bot/some-bot-app "path/to/bot.sh"

第1引数のsome-bot-appはロックのキー、 第2引数のpath/to/bot.shロック取得成功時に実行するコマンドです。 同一のキーでロックが取得されている場合は第2引数に指定されたコマンドを実行せず、 ロックが取得できるまで待つようになっています。 ロックは第2引数のコマンドが終了すると同時に解放されます。

(botアプリケーション自体の不具合で再起動が必要なのであればdaemontoolsなどで起動すれば事足りるのですが、 それが起動しているホストごと不調になった場合にそなえてこのような仕組みが必要になります)

もちろんconsul lockを使った冗長化で対応できるのは、 botアプリケーションが捌くイベントがひとつのアプリケーションで対応できる範囲に収まる場合だけです。 より大規模なbotアプリケーションを冗長化する際には別の解決方法が必要になるでしょう。

再起動時の自動組み込み防止 (maint)

Lobiではアプリケーションからデータベースのslaveに接続する際に HAProxyが設定されたweightに基づいてどのslaveに接続するべきかを決定します。 接続先として適当かどうかはMHAから各データベースslaveに対してヘルスチェックを実行することで判断しています。

以下はhaproxy.cfgの一部です。 ここでは、

  • 3台のデータベースslaveについて
  • ヘルスチェックを各ホストに対して行い
  • weightの偏りなく接続を振り分ける

よう設定しています。


listen  mysql-slave-main
        bind            0.0.0.0:3307
        mode            tcp
        option          httpchk
        balance         roundrobin
        server          db-slave01  db-slave01:3306 weight 100 check port 13307 inter 5000 rise 3 fall 3
        server          db-slave02  db-slave02:3306 weight 100 check port 13307 inter 5000 rise 3 fall 3
        server          db-slave03  db-slave03:3306 weight 100 check port 13307 inter 5000 rise 3 fall 3

各ホストのヘルスチェック応答用のアプリケーションはdaemontoolsで実行され、 ホストの起動時に自動的に起動します。 つまり、起動と同時にデータベースslaveへの接続が手配されるわけですが、 起動直後はメモリ上にデータが載っておらず、disk readが発生する確率が非常に高くなっています。 この状態で他のホストと同じweightで接続を振り分けるのは危険です。

再起動前にconsul maintを使って ホストをメンテナンス状態にしておくことでこの問題を解決しています。


$ consul maint -enable "データベース設定変更"

ヘルスチェック応答用アプリケーションでconsul maintの状態を確認し、 有効であればアプリケーション自体が起動しないようにしています。 consul maintの設定状況はconsul serverが保持しているため、 対象ホストの再起動等でこの情報が失われることはありません。


# consul がメンテ状態の場合は起動しない
maint=$(consul maint)
if [[ $maint != "" ]]; then
    echo "$maint"
    sleep 10
    exit 1
fi

# 以下ヘルスチェック応答用アプリケーション起動処理
...

HAProxyの設定で対象のデータベースslaveについてweight=0にしてしまえば同様の結果は得られるのですが、 そのためにはHAProxyが稼働している全てのホストで設定変更が必要になります。 consul maintを有効にしてヘルスチェックが失敗するようにすれば 対象のデータベースについての変更のみでクラスタ内のすべての関連ホストからの接続を排除することができるのです。

また、consul maintを有効にしておけばconsulが提供するserviceの名前解決の対象からもはずれます。 HAProxyなどの仕組みを使わない場合はこれによりそのホストへの接続等を制御することができます。

Lobiにおけるデータベースの冗長化については以下を参照下さい。

LobiのDB masterがダウンすると何が起こるのか

スパムIPリストの更新 (consul-template)

Lobiはチャットを主軸としたサービスです。 特定のメンバーのみが閲覧できるプライベートなチャットグループの他に、 誰でも参加・閲覧できる公開チャットグループがあります。

公開チャットグループはLobiのアカウントさえあれば誰でも書き込みができるという性質上、 一定の割合でスパム投稿や荒らし投稿が発生します。 これについていくつか対策を講じているのですが、そのひとつとしてIPアドレス単位のアクセス拒否を行なっています。

とある場所から拒否するべきIPアドレスのリストを取得しnginxのdeny対象として設定しているのですが、 この設定の反映にconsul-templateを使用しています。

consul-templateはconsul kvの変更をトリガーに、

  • 対象ファイルの更新(nginx設定ファイルの更新)
  • コマンドの実行(nginx reload)

を行うことができます。


script(run by crontab) ---> consul kv ---> consul-template ---> update nginx conf, exec nginx reload

consul kvの変更はcrontabに登録したスクリプトで定期的に行うようにしています。

以前は同様のことをchefのdata bagとtemplateを使って行なっていました。 chef実行の前処理として拒否対象IPアドレスをdata bagに保存し、 chef実行時にdata bagの内容を元にtemplateからnginx設定ファイルを更新、nginxをreloadする、という流れです。

この方法の問題点として、

  • chef実行は頻繁に行われるわけではないので拒否対象のIPアドレスリストが最新に保てない
  • 拒否対象IPアドレスリストは頻繁に変更されるためchefのレポートが肥大化する

といったものがありましたが、consul-templateを用いることで解決しています。

ダッシュボード (members, kvs)

クラスタ内の各種ステータスを一覧する場所として Consul KV Dashboardを使用しています。

Consul KV DashboardはConsulをバックグラウンドとして使用しているダッシュボードアプリケーションです。 Lobiでは、

  • 各ホストのデプロイログ
  • 各ホストのserverspecの実行ログ
  • 定期実行処理の実行ログ

などをこのダッシュボードで一覧できるようにしています。

各ログはconsul kvsに保存され、 ノードリストはconsul membersを元に表示されるため、 たとえばホストをシャットダウンした場合は自動的にダッシュボードからは非表示になります。

dashboard.png

コマンドの発行 (exec)

consul execを使用すると クラスタ内のホストに対してコマンドを発行することができます。


$ consul exec -node "hostname regexp" "command"

-nodeパラメータで対象ホストをホスト名で絞り込むことができます。 Lobiではこれを利用してデプロイ時のdaemontools管理のアプリケーションの再起動を行なっています。


$ consul exec -node "lobi-app-" "sudo svc -h /service/app"

注意点として、コマンドの実行はconsul userとして各ホストで実行されるため、 sudoするが必要な場合はsudoersでconsul userにそのコマンドのパスワード無し実行を許可する必要があります。


consul  ALL=NOPASSWD: /command/svc

用途が限定的なコマンドであればNOPASSWDに設定しても問題はないでしょう。 汎用的なコマンドを設定する場合は本当にその必要があるのかを充分に検討して下さい。

ssh先の選択 (members)

OSの設定変更やミドルウェアのインストール・アップデートはchefで行なっているため、 通常であれば各ホストに直接sshログインすることはありません。 しかし、不具合発生時の調査など、sshログインが必要になる場合は発生します。

Lobiでは常時数十台以上のホストが稼働しており、なおかつAuto Scalingしているため、 sshログイン先のIPアドレスをて入力するのは現実的ではありません。

そこで.bashrcに以下のような関数を定義し、sshログインの手間を簡略化しています。


function lobi_ssh() {
    host="$(consul members | grep alive | cut -d' ' -f1 | sort | peco)"
    if [[ -n $host ]]; then
        ssh "$host"
    fi
}

pecoはリスト内のインクリメンタルな検索機能を提供する汎用的なツールです。 consul membersで取得したホストリストから稼働中のホストを抽出しpecoに渡し、 インクリメンタルに検索してsshコマンドを実行する、という関数になっています。

members.png

いちいちホスト一覧を参照してIPアドレスを調べる手間もなく、大変便利です。

参考資料

おわり

Consulの様々な活用方法を紹介しましたが、これらすべてを利用する必要はありません。 その必要な機能を必要な部分で採用し、クラスタの管理を簡略化していきましょう。

次回はLobiのstreaming APIについて紹介します。

カヤックではツールを使ってインフラ管理を楽にしたいエンジニアも募集しています!

スマートフォンのネイティブアプリをメインにサービス展開しているLobiですが、 Webブラウザからも利用することができます。

https://web.lobi.co

Webブラウザからでもチャットの投稿・閲覧が行えます。

このサイトは AngularJS を用いて実装されています。

AngularJSとは

AngularJSはGoogleが提供しているJavaScriptライブラリです。

https://angularjs.org/

Webページ内でのユーザーのアクションに対応するコンテンツ生成や表示変更などをJavaScriptで強力に実現します。

SEOとの相性

AnguraJSに限らず、JavaScriptで動的にコンテンツを生成するWebページには、 SEOとの相性が悪いという欠点があります。 これは検索エンジンのクローラーがJavaScriptの実行までは行わないことに起因しています。 真っ白なページにJavaScriptでコンテンツを追加していくような作りになっている場合、 クローラーからは「真っ白なページ」として認識されてしまいます。

これではせっかくコンテンツを用意しても、検索エンジンからは存在しないものとして扱われてしまいます。 LobiのWebブラウザ版(https://web.lobi.co/)はまさにその状態でした・・・。

最初期

当時LobiはiOSとAndroidのネイティブアプリとしてサービス提供を行っていました。 ユーザーからの「PC版はないのか?」という要望から、Webブラウザで動作するLobi(以下「Web版」と略記)を作ることになりました。

ネイティブアプリと同等の操作性を提供したいとの考えから、 AngularJSを用いたSingle Page Applicationとして開発が進められました。

その後、単なるチャットツールではなくコミュニティとしてのLobiを拡散する施策を行うにあたり、 Web版のSEOに着手したのですが・・・上記の通りAngularJSで動的にコンテンツを生成していたため、 クローラーからは「真っ白なページ」として認識されているという事実が大きな足かせになりました。

Prerender.IOでレンダリング

クローラーにコンテンツを認識してもらうためには、 サーバーでページをレンダリングするしかありません。 かといってAngularJSでフル実装されたWeb版を サーバーサイドレンダリング方式に実装し直すには大きなコストがかかってしまいます。

そこで「JavaScriptによるコンテンツ生成を維持したままサーバーでレンダリングする」という手段をとることにしました。 当時使用していたのはperenderというツールです。

https://prerender.io/

これでレンダリング済みのページを返すことでインデックスされるようにはなった・・・のですが、 prerenderのプロセスが頻繁にエラー終了してしまうため代替手段を用いる必要がありました。

暫定対応

暫定対応として、クローラーからのリクエストに対しては コンテンツとして重要な要素(チャットグループのタイトル、最新の発言数件、など)を サーバーでレンダリングして返すような実装を行っていました。

しかし、ユーザーが見るコンテンツとクローラーが見るコンテンツを出し分ける行為は、 「クローキング」というガイドライン違反にあたるため、早急に別の手を打つ必要がありました。

クローキング - Search Console ヘルプ

なお、Googleのクローラーから見たがWebページをどのように表示されているかは、 Search ConsoleのFetch as Googleを使用することで確認することができます。

ウェブサイト用 Fetch as Google を使用する - Search Console ヘルプ

phantomjsでレンダリング

ここからが本番。

prerenderと同じアプローチではありますが、JavaScriptを解釈し、 ブラウザで表示されるものと同じページレンダリングを行う手段として PhantomJSを採用することにしました。

http://phantomjs.org/

レンダリング処理

上述の通りPhantomJSでレンダリングを行なっているわけですが、 JavaScriptを解釈しサーバーでレンダリングするためには、 AngularJSで全コンテンツを動的生成している場合はとりわけ時間がかかります。

Lobiではこの描画処理をバックグラウンドのワーカーに行わせています。 リクエストからレスポンスまでのシーケンス図は以下のようになります。

sequence.png

  • クローラーからリクエストがあった場合、まずレンダリング結果のキャッシュを参照する
  • キャッシュがあればそれを返して終了

新規ページ等はキャッシュが作られていないので、レンダリング処理が実行されます。

  • バックエンドのアプリケーションがレンダリング用ワーカーを起動
  • nginxにはいったんX-Accel-Redircetヘッダーを付けてレスポンスを返す
  • X-Accel-Redirectには「一定時間おきにキャッシュ取得をくりかえす」locationが指定されている
  • キャッシュ取得を繰り返す処理にはngx_lua_moduleを使用
  • ワーカーによるレンダリングが完了し、nginxがキャッシュ取得に成功したらそれをレスポンスとして返す
  • 一定時間以上キャッシュが取得できなかった場合はRetry-Afterヘッダーをつけた上でstatus=503としてレスポンスを返す

X-Accel-Redirectはnginxが独自に解釈するヘッダーで、 upstreamからのレスポンスにこのヘッダーが指定されていた場合、 その値に対応するlocationに処理を引き継ぐことができます。

nginxの設定から該当箇所を抜粋すると以下のようになります。


upstream app {
    server 127.0.0.1:8080;
}

location / {
    if ($http_user_agent ~* "baiduspider|googlebot|bingbot") {
        set $cache_key "$cache_key_prefix$host$request_uri";
        memcached_pass 127.0.0.1:11211;
        error_page 404 503 = @fallback;
    }
}

location @fallback {
    proxy_pass http://app;
    # proxy先から X-Accel-Redirect: /wait_cache が返ってくる
}

location /fetch_cache {
    internal;
    include /etc/nginx/nginx.common.location.proxy.conf;
    memcached_pass 127.0.0.1:11211;
}

location /wait_cache {
    internal;
    default_type "text/html; charset=utf-8";
    memcached_gzip_flag 2;
    gunzip on;

    if ( $upstream_http_x_cache_key ) {
        set $cache_key $upstream_http_x_cache_key;
    }

    access_by_lua '
        while true do
            ngx.sleep( 2 );

            local res = ngx.location.capture( "/fetch_cached", {
                share_all_vars = true
            });

            if res.status == 200 then
                return;
            end

            if 30 < ngx.now() - ngx.req.start_time() then
                ngx.header.Retry_After = 60;
                ngx.header.Content_Type = nil;
                ngx.exit(ngx.HTTP_SERVICE_UNAVAILABLE);
                return;
            end
        end
    ';

    memcached_pass 127.0.0.1:11211;
}

詳しくはnginxのドキュメントを参照して下さい。

https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/

弊社サービスで実際に使われているngx_lua_moduleの活用方法については以下のスライドを参照下さい。

https://speakerdeck.com/fujiwara3/practical-nginx-lua-in-kayac

ストリーミングAPIの罠

LobiはチャットをメインコンテンツとするWebサービスです。 ブラウザでチャットグループを開いておくと新着メッセージが自動的に表示されるようになっています。 この仕組はクライアントとAPIサーバー間でHTTP接続を維持することで実現しています。 この接続は最長10分間継続します。

つまり、PhantomJSで何も考えずにグループチャットのページをレンダリングすると、 処理完了まで10分間かかることになります。

クローラー用のページではその時点で投稿されているメッセージが描画されていれば充分なので、 ストリーミングAPIを使用する必要がありません。 そこで該当APIのリクエストをキャンセルするようにしました。


var page = require('webpage').create();
page.onResourceRequested = function(requestData, networkRequest) {
    if (isStream.test(requestData['url'])) {
        networkRequest.abort();
    }

    ...
});

ストリーミングAPI以外の外部リクエスト(Google AnalyticsへのAPI送信など)についても、 表示されるコンテンツに影響しないものについてはリクエストをキャンセルすることで レンダリング速度を上げる工夫をしています。

(prerenderでレンダリングしていた際には時間が足りず調べきれなかったエラー終了の原因も、 ストリーミングAPIによるものである可能性が高いと考えています)

SEOを考えている場合AngularJSという選択は・・・

先のシーケンス図を見てもらえば分かる通り、AngularJSを使ったサイトできちんとSEOしようとすると かなり複雑な仕組みを用意しなければなりません。 しかもこれは「SEOするための準備」であって、SEOそのものではないのです・・・。

SEOを考えるのであれば、AngularJSをはじめJavaScriptでコンテンツを生成するライブラリの利用は諦めたほうが無難でしょう。

次回

次回はLobiにおけるConsulの活用事例について紹介します。

カヤックではSEOに興味があるエンジニアも募集しています!