スクリーンショット_2019-06-22_19

React SPA を Rendertron で Dynamic Rendering している話

React に限らず SPA (Single Page Application) では何かと SEO 対策が問題となります。
よく知られているように、サイトをクロールする多くの Bot (Facebook, Twitter, Slack etc.) が JavaScript を評価・実行してくれないからです。

JavaScript が評価されなければ、基本的に JavaScript によって画面を描画したりメタデータを動的に生成したりする SPA は、Bot に対してただのまっさらな HTML を返すだけになり、ページが本来持つ情報を正しく評価してもらうことができません。

たとえば、SPA の URL をそのまま Facebook や Twitter といった SNS でシェアしてみると、タイトルも画像も何も展開されないことがわかります (もちろん、サーバーサイドでメタデータを設定していた場合は別です)。表に出ている情報が何もないサイトの URL をクリックしようという気にはなかなかなりませんから、これはゆゆ式事態です。対策が必要です。

Googlebot も JavaScript を実行してくれるとかくれないとか言われることがありますが、Googlebot は一応 JavaScript の実行自体はしてくれます。
Search Console でレンダリング結果を確認してみると、確かに JavaScript が実行され、画面が描画されていることが確認できます。

しかしこれは罠です。なるほど JavaScript を解釈してくれるなら React Helmet による <head> タグ内のコンテンツの動的書き換えももちろん有効だよな!やったぜ!と思ったのですが、Search Console 上でレンダリングされた HTML をよくよく見てみると、<head> タグ内が何も書き換わっていないことが確認できます。どうやら Googlebot は <head> タグ内のコンテンツの動的書き換えは検知してくれないようです (React Helmet の Issue にも上がっていました)。

さて、困りました。Bot からのアクセスのときはサーバーサイドでメタデータだけ埋めて返すか? (ルール的にグレーだし無駄に複雑になってる感が否めない...)。あるいはサーバーサイドレンダリングするか? (考えること増やしたくないので SSR は可能な限りやりたくない...)。

いろいろ悩んでいたのですが、この問題に対するクリティカルな対策になりそうなものが最近出てきました。それが、今回紹介する Rendertron を使った Dynamic Renderingです。

Rendertron は内部で Puppeteer (Headless Chromium) を使っており、こいつに HTTP でリクエストすると JavaScript を評価・実行済みの HTML を生成して返してくれるというわけです。

基本的な使い方は↑の公式記事の通りなのでそちらを読んでいただくのが手っ取り早いし正確だと思うのですが、実際に使ってみたらいろいろと詰まりポイントというかそのままではどうにも使えない部分があったので、こちらの記事ではそういった点をいくつか書いていきたいと思います。

1. Docker コンテナでのデプロイが想定されていない

どうやら GAE を使ってほしいようで、Dockerfile 等は用意されていません。
なので、GKE や Cloud Run といった環境にデプロイしたい場合は自前で用意する必要があります (今回は Cloud Run にデプロイします)。

Dockerfile ですが、基本的には Rendertron が内部で使用している Puppeteer (Headless Chromium) が動作する環境が作れれば大丈夫です。

参考
https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md#running-puppeteer-in-docker
https://github.com/GoogleChrome/rendertron#deploying-using-docker

Rendertron のビルド/起動方法については README に書いてありました。

以上を踏まえての成果物↓

# rendertron/Dockerfile

FROM node:10.16.0-slim

RUN apt-get update && \
  apt-get -y install xvfb gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 \
  libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 \
  libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 \
  libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 \
  libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget git && \
  rm -rf /var/lib/apt/lists/*

RUN mkdir /rendertron
WORKDIR /rendertron

COPY . /rendertron

RUN npm install
RUN npm run build

ENV PORT 80
EXPOSE 80
CMD ["npm", "run", "start"]

また、これは Docker コンテナで Headless Chrome/Chromium を動かす際のあるあるですが、Chromium の起動オプションに --disable-dev-shm-usage を付けてあげる必要があります。これがないと Chromium はコンテナの容量 64MB の共有メモリ ( /dev/shm ) を使い切って、大抵レンダリング時に Page crashed! します。

Error: Page crashed! at Page._onTargetCrashed (/rendertron/node_modules/puppeteer/lib/Page.js:176:24) at CDPSession.Page.client.on.event 

詳細はこちら
https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md#tips

前述の通りなぜだか Rendertron は Docker コンテナでの動作をサポートする気がないようで (以前 Dockerfile が置かれていた形跡はあるんですがなぜ消されたのかよくわからない...)、こちらのオプションには対応していません。迷った挙げ句、fork して書き加えることにしました。

# rendertron/src/rendertron.ts

- const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
+ const browser = await puppeteer.launch({
+   args: [
+     '--no-sandbox',
+     '--disable-dev-shm-usage'
+   ]
+ });

2. 自サイトへの Puppeteer の最適化 - パフォーマンスチューニング

Rendertron をそのまま動かしていると、何度やってもレンダリングに 10 秒くらいかかっている (というかタイムアウトしている) ようで、さすがにこの遅さじゃ使い物にならないなーこんなに遅いもんなのかなーと思っていたのですが、結論としては自サイト内の激重リクエストが原因でした。

TimeoutError: Navigation Timeout Exceeded: 10000ms exceeded

Rendertron は、その内部で使用している Puppeteer の機能によって、レンダリング時に指定したページ上で行われる全てのリクエストが完了するのを待機することで、JavaScript による非同期リクエスト実行済みの HTML を返すということを実現しています。

ということは、一つでも完了しないリクエストがあれば、それがボトルネックとなってタイムアウトが頻発するということが起きます。

この問題への対処として、指定したエンドポイント (Firestore) へのリクエストは abort するように、自サイト向けに Puppeteer の挙動を調整しました。

# rendertron/src/renderer.ts

+ await page.setRequestInterception(true);
+
+ page.on('request', request => {
+   if (request.url().includes('https://firestore.googleapis.com')) {
+     request.abort();
+   } else {
+     request.continue();
+   }
+ });

また、Puppeteer のリクエスト待機の設定として、デフォルトでは networkidle0 が指定されているのですが、私のサイトの場合、前述のような異常に重いリクエストがたまに発生します。実際には、これらのリクエストが完了しなかったとしても Bot に返すべきコンテンツの生成は正しく完了するので、これを若干緩和して networkidle2 としました。

これで、500 ミリ秒以上の間、ネットワーク接続が 2 つ以下になったときにナビゲーションを終了するという挙動になります。

# rendertron/src/renderer.ts

 try {
   // Navigate to page. Wait until there are no oustanding network requests.
   response = await page.goto(
-    requestUrl, {timeout: this.config.timeout, waitUntil: 'networkidle0'});
+    requestUrl, {timeout: this.config.timeout, waitUntil: 'networkidle2'});
 } catch (e) {
   console.error(e);
 }

以上の対応によって、なんとか許容できるレベルのパフォーマンスが確保されました。頻発していたレンダリング時のタイムアウトもほとんど発生しなくなりました (なくなったとは言ってない)。

Slack で URL をシェアしたりすると、ページの情報が展開されるまでに若干のタイムラグがあるのが気になりますが、とりあえず Facebook/Twitter 等の Bot が諦めてタイムアウトしてしまうという事態は免れたので、一旦良しとしましょう。

できれば Rendertron のコードに手を加えずに乗り切りたかったのですが、今回は致し方なし...。

3. Cloud Functions 環境で rendertron-middleware が使えない

公式のドキュメントにも記載がありますが、Rendertron は express 用に rendertron-middleware を用意してくれています。

私のサイトは Firebase Hosting + Cloud Functions (express サーバー) の構成を取っていたので、この middleware がそのまま使えるかなと思ったのですが、だめでした。

Cloud Functions 上で使った場合、レンダリング対象の host が自動で Cloud Functions のエンドポイント (*.cloudfunctions.net) になってしまい、正常にレンダリングが行われませんでした。

なので、Rendertron にレンダリングをリクエストする部分は Fetch を使って自前で実装しました。

ざっとこんな感じになっています↓

# functions/index.js

const functions = require('firebase-functions');
const express = require('express');
const fs = require('fs');
const http = require('http');
const fetch = require('node-fetch');
const url = require('url');

const isBot = require(__dirname + '/utils/isBot');

const generateUrl = req => {
  return url.format({
    protocol: 'https',
    host: process.env.SITE_DOMAIN,
    pathname: req.originalUrl
  });
};

const app = express();

app.use(express.static(__dirname + '/../hosting'));

app.get('*', async (req, res) => {
  if (isBot(req)) {
    console.log(`Bot access: ${req.headers['user-agent']}`);

    const response = await fetch(
      `${process.env.RENDERTRON_ENDPOINT}/render/${generateUrl(req)}`
    );
    const body = await response.text();

    res.set('Cache-Control', 'public, max-age=300, s-maxage=600');
    res.set('Vary', 'User-Agent');

    res.send(body.toString());
  } else {
    res
      .status(200)
      .send(fs.readFileSync(__dirname + '/../hosting/index.html').toString());
  }
});

exports.host = functions.https.onRequest(app);

参考
https://firebase.google.com/docs/hosting/functions?hl=ja

4. 課題

一応使えるレベルには至りましたが、やはり速度面に改善の余地があります。

レンダリング処理自体の速度はそれなりに改善できたのですが、アプリ (Cloud Functions) ↔ Rendertron (Cloud Run) 間のアクセスが現状インターネット経由になっており、ここでのレイテンシが気になります。

できれば Private IP によるアクセスを試したいのですが、Cloud Run はまだ VPC に繋がっていない & リージョンも現状 us-central1 しか選べません。どうやら近い内に対応してくれるらしいという情報は出ているので、こちらに関してはしばし待機です。

まとめ

・Rendertron による Dynamic Rendering を利用することで、React SPA でも Bot に対して正しい情報を返すことができるようになりました。


Qoodish のメタデータは Rendertron によって出力されています。
よろしくお願いします!

https://qoodish.com/maps/151

......。

...... notebot 側でタイムアウトしてるみたいで展開されない......つらい...... (今回のオチ)。

 (note の bot の User-Agent は notebot なんですね。bot list に加えておきました)。

パフォーマンス改善は継続していきたいと思います。

⚠ 2020/1/7 追記
・Cloud Run で 2 vCPU が使えるようになりました 🎉
・Cloud Run で asia-northeast1 リージョン が選択できるようになりました 🎉

Private IP による接続 (VPC 接続) はまだ実現できていませんが、デプロイ時に 2 vCPU 2Gi メモリ & asia-northeast1 リージョンを選択することで、Cloud Run でも十分なパフォーマンスが出せるようになりました。

この記事が気に入ったらサポートをしてみませんか?