Ameba OwndのSEOを支える技術 for AngularJS | サイバーエージェント 公式エンジニアブログ

こんにちは、サーバーサイドのエンジニアをやっているoinumeです。今回は昨年8月ぐらいから作っていたAmeba Owndというサービスで行ったSEO対策について紹介します。

AmebaOwndって?

ブログ機能を備えたスタイリッシュなデザインのWebサイトを簡単に作成できるサービスです。

などのサイトがAmeba Owndを利用して作られています。

アーキテクチャ

ユーザーさんがWebブラウザでアクセスするページについてはAngularJS + REST API(Nginx + Go)で作られています。一方でGooglebotなどのクローラーからのアクセスの場合は、受けたリクエストをNginxがPrerender CacheというシステムにProxyして、このPrerender CacheからHTMLを返すようにしています。


AngularJSのSEO対策(なぜPrerender Cacheを使ったか)

現在のGooglebotは公式に発表されている通り、JavaScriptを実行することができます。ただ、

  1. どこまでちゃんとJavaScriptが実行できるのかわからない(Angular本当にちゃんと動くの?)
  2. レンダリングが途中でストップされ、コンテンツが中途半端にインデックスされないか
  3. Googlebot以外はJavaScriptが実行できるかよくわからない(中国語圏のBaidu, 韓国語圏のNaverなど)
  4. 開発スケジュール的に1.や2.をちゃんと検証している時間がなかった

が不安要素としてあったため、あえてVarnish + PhantomJS によるキャッシュシステムを用意しました。それがPrerender Cacheと呼んでいるものになります。

Prerender Cacheのアーキテクチャ

Varnish+Prerender(Node.JS + PhantomJS) という組み合わせになっています。Prerenderの前段にVarnishがいるのは、PhantomJSがHTML + JavaScriptをレンダリングするのに5秒以上かかるケースがあったため、レンダリング済みのページはキャッシュし、Googlebotに対して高速にレスポンスを返せるようにするためです。実際にはVarnishとPrerenderの前にELBとNginxがいるため、下の図のようにもう少し複雑なアーキテクチャになっています。

処理の流れは以下のようになります。

  1. https://starbucks.amebaownd.com/にGooglebotからのアクセスが来る
  2. NginxがPrerender CacheにProxyする
  3. Varnishに該当URLのキャッシュがある場合はそれを返す
  4. Varnishにキャッシュがない場合はlocalhost:8081で稼働しているNginxを通してPrerenderにProxyする
  5. PrerenderのPhantomJSがhttps://starbucks.amebaownd.com/にアクセスして、レンダリング結果を返す
  6. Varnishがキャッシュしてレスポンスを返す

Nginx

具体的な設定ファイルを見て行きましょう。まず、一番最初にリクエストを受けるNginxの設定です。$prerender = 1の場合はバックエンドのPrerender CacheのELBにProxyしています。
# Prerender.io
set $prerender 0;


if ($http_user_agent ~* "applebot|baiduspider|bingbot|bingpreview|developers\.google\.com|embedly|googlebot|gigabot|hatena::useragent|ia_archiver|linkedinbot|madridbot|msnbot|rogerbot|outbrain|slackbot|showyoubot|yahoo! slurp|Y!J-|yandex|yeti|yodaobot") {
  set $prerender 1;
}
if ($args ~ "_escaped_fragment_") {
  set $prerender 1;
}
if ($args ~ "prerender=true") {
  set $prerender 1;
}
if ($http_user_agent ~ "Prerender") {
  set $prerender 0;
}
if ($uri ~ "\.(js|css|xml|less|png|jpg|jpeg|gif|pdf|doc|txt|ico|rss|zip|mp3|rar|exe|wmv|doc|avi|ppt|mpg|mpeg|tif|wav|mov|psd|ai|xls|mp4|m4a|swf|dat|dmg|iso|flv|m4v|torrent)") {
  set $prerender 0;
}


# Resolve every 10 seconds
# http://d.hatena.ne.jp/hirose31/20131112/1384251646
resolver <resolver ip="None"> valid=10s;


if ($prerender = 1) {
  #setting prerender as a variable forces DNS resolution since nginx caches IPs and doesnt play well with load balancing
  set $backend_prerender_varnish "<elb fqdn="None">";
  rewrite .* /$scheme://$host$request_uri? break;
  proxy_pass http://$backend_prerender_varnish;
  break;
}


proxy_pass http://backend:3000/;

Varnish

/etc/varnish/default.vcl

VarnishのVCLファイルは以下のようになっています。

vcl 4.0;


# Default backend definition. Set this to point to your content server.
backend default {
    .host = "127.0.0.1";
    .port = "8081";
    .connect_timeout = 3s;
    .first_byte_timeout = 20s;
    .between_bytes_timeout = 10s;
}


sub vcl_recv {


    if (req.http.User-Agent ~ "(?i)i(Phone|Pad|Pod)") {
        set req.http.X-UA-Device = "sp";
    }
    else if (req.http.User-Agent ~ "(?i)Android") {
        set req.http.X-UA-Device = "sp";
    }
    else if (req.http.User-Agent ~ "(?i)Googlebot-Mobile") {
        set req.http.X-UA-Device = "sp";
    }
    else {
        set req.http.X-UA-Device = "pc";
    }


    if (req.method != "GET" &&
      req.method != "HEAD" &&
      req.method != "PUT" &&
      req.method != "POST" &&
      req.method != "TRACE" &&
      req.method != "OPTIONS" &&
      req.method != "DELETE") {
        /* Non-RFC2616 or CONNECT which is weird. */
        return (pipe);
    }


    if (req.method != "GET" && req.method != "HEAD") {
        /* We only deal with GET and HEAD by default */
        return (pass);
    }


    if (req.http.Authorization) {
        /* Not cacheable by default */
        return (pass);
    }


    return (hash);
}


sub vcl_hash {
    if (req.http.X-UA-Device) {
        hash_data(req.http.X-UA-Device);
    }
}


sub vcl_backend_response {
    if (beresp.status == 401 ||
        beresp.status == 402 ||
        beresp.status == 403 ||
        beresp.status == 404 ||
        beresp.status == 500 ||
        beresp.status == 501 ||
        beresp.status == 502 ||
        beresp.status == 503 ||
        beresp.status == 504) {


        set beresp.ttl = 0d;
    } else {
        set beresp.ttl = 24h;
    }


    return (deliver);
}


sub vcl_backend_error {
    return (retry);
}


sub vcl_deliver {
    if (obj.hits > 0) {
       set resp.http.X-Cache = "HIT";
    } else {
       set resp.http.X-Cache = "MISS";
    }
}

/etc/nginx/conf.d/localhost.conf

Varnishのサーバに同居しているNginxの設定は以下のようになっています。VCLファイルに名前解決が必要なELBのドメインを指定するとエラーになるため、このように間にNginxを挟んでいます。

server {
  listen 8081;
  server_name 127.0.0.1 localhost;
  root /usr/share/nginx/html;
  index index.html index.htm;


  access_log /var/log/nginx/prerender/access.log combined;
  error_log  /var/log/nginx/prerender/error.log;


  location / {
    resolver {{ nginx.resolver }} valid=10s;
    set $backend "<elb fqdn="None">";
    proxy_pass http://$backend;
  }


  error_page 404 500 502 503 504 /50x.html;


  # redirect server error pages to the static page /50x.html
  #
  location = /50x.html {
    root /usr/share/nginx/html;
  }


  location ~ /\.ht {
    deny all;
  }


  location /nginx_status {
    stub_status on;
    access_log  off;
    allow       127.0.0.1;
    deny        all;
  }
}

PC版とスマホ版のキャッシュ分かれてない問題

徐々にコンテンツが集まってきて順調にGoogleにインデックスされるようになってきたように見えたのですが、一つ落とし穴がありました。というのは、GoogleはPCサイトのインデックスとスマホ向けのインデックスが分かれているため、PC/スマホで返すHTMLを分けなくてはいけません。最初のバージョンのPrerender Cacheではこのことが考慮されておらず、スマホ版のGooglebotにもPC向けのHTMLを返すようになってしまっていて、危うくスマホの検索結果からインデックスが消えてしまいそうになりました。

よって以下の対応を施しました。

これにより無事PC/スマホで別々のHTMLをキャッシュして返せるようになりました。

まとめ

AngularJSのSEO対策は大変です。SEOが必要なサイトをAngularJSで作る場合は、きちんと検証するかPrerender Cacheのようなシステムを作る必要があることを事前に認識しておきましょう:-) もしくはReactを使ってServer Side Renderingがナウいやり方かもしれません。