SMARTCAMP Engineer Blog

スマートキャンプ株式会社(SMARTCAMP Co., Ltd.)のエンジニアブログです。業務で取り入れた新しい技術や試行錯誤を知見として共有していきます。

LighthouseをFirebase Functionsから毎日叩いて本番環境のパフォーマンスを計測してみた

スマートキャンプの笹原です。

みなさんはWebサイトの、特にフロントのパフォーマンス改善を日頃から行っていますか?

常に意識しているという方もいれば、気が向いたときにたまに見てみるなんてこともあるんじゃないかと思います。

今回はそんなパフォーマンスに常に意識を配れるように、毎日Lighthouseを叩いてみたのでその構成を紹介したいと思います。

Lighthouseとは

まずはLighthouseについて簡単な説明です。
LighthouseとはWebサイトのパフォーマンスや品質を計測するツールで、コマンドラインツールもしくはChromeの拡張機能として提供されています。

WebサイトのURLを指定することで、Performance, Accessibility, Best Practices, SEOの5つの観点からそれぞれ100点満点として採点されたレポートを生成することができます。

最近では、Lighthouse CIという、LighthouseをCI実行のタイミングでトリガーさせることが出来るツールも出てきています。

こちらについては、弊社の中川が試してみた記事があるので、こちらもご覧になってください。

tech.smartcamp.co.jp

要件

今回は以下の要件でLighthouseを定期実行しています。

  • 本番環境を対象にして実行したい
  • 対象となるページは本番環境のデータに変更に追随して変えたい
  • 連動含め時系列的に並べて変化を捉えたいので、日次で実行した結果をBigQueryに保存したい

こういった要件があったため、Lighthouse CIなんていう、いかにも定期的に実行するのに向いていそうなツールが登場している中、今回はLighthouse CIを用いませんでした。

もちろん、Lighthouse CIでも設定を変更することで上記の要件を叶えられる可能性もありましたが、基本的にはCI環境上でcommitを対象にして検証することが得意そうに思われたので、利用するのは見送りました。

処理の流れと制約

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

  1. BigQueryのデータを元に対象となるページを抽出する
  2. 対象となるページごとに以下の処理を実行する
    1. Lighthouseの実行
    2. BigQueryに実行結果を保存する

2-aについては実際に本番環境を叩くので、その頻度は稼働に問題ない状態にする必要があります。

とはいえ、1ページあたり10~30秒程度計測にかかるので、すべてのページを直列で行っているとページ数によっては実行時間がかかりすぎてしまいます。

なので、並列で実行可能なようにしながら、その並列数には上限を設けられる形での動作が求められます。

実際の構成

上記のような要件と制約を踏まえて、以下のような構成にしました。

f:id:yuma124:20200326201558p:plain

1. 定期的にCloud Tasksに各ページごとのTaskをEnqueueする

TaskをEnqueueされるCloud Tasksのキュー作成

まず、Cloud Tasksのキューを作成します。

$ gcloud tasks queues create lighthouse

次にキューの設定をしていきます。

本番環境を叩くことになるので、実行間隔や並列数には制限を設けたいです。

例えば、タスクの最大速度を1件/秒とし、最大並列数を5にする場合は以下のコマンドを叩きます。

$ gcloud tasks queues update-app-engine-queue lighthouse \
    --max-dispatches-per-second=1 \
    --max-concurrent-dispatches=5

以下が公式ドキュメントになるので、その他の設定をしたい場合はご覧になってください。

Creating Cloud Tasks queues  |  Cloud Tasks Documentation  |  Google Cloud

TaskをEnqueueするFunctionの作成

定期実行のTriggerはCloud Pub/SubのCronを利用します。

こちらはFirebase FunctionsでTriggerをPus/SubにしたFunctionをdeployするだけで登録できます。

このTriggerで起動されたFunctionが実行するのは以下の2点です。

  1. BigQueryからLighthouseを実行するべきページを取得する
  2. 各ページごとに、Cloud TasksにFirebase Functionsを叩く処理をEnqueueしていく

EnqueueするのはHTTP Targetタスクにします。

HTTP Targetタスクはその名の通りHTTPリクエストを投げるタスクになっており、後ほど作る各ページにLighthouseを実行するFirebase FunctionをHTTPリクエストで実行します。

実際の流れはこんな感じになります。

const client = new CloudTasksClient();

const project = 'project-id';
const queue = 'lighthouse';
const parent = client.queuePath(project, tokyoRegion, queue);

const httpMethod = 'POST';
const headers = {'Content-Type': 'application/json'};
const functionEndpoint = 'https://asia-northeast1-project-id.cloudfunctions.net/audit-url';

export const enqueueLighthouse = functions
  .regions('asia-northeast1')
  .pubsub.schedule('0 0 * * *')
  .timeZone('Asia/Tokyo')
  .onRun(async () => {
    const targetUrls = await fetchTargetUrls();  // BiqQueryからターゲットとなるURLを取得する
    if (targetUrls.length === 0) {
      return;
    }

    await Promise.all(targetUrls.map((url) => {
      const payload = JSON.stringify({ url });
      const body = Buffer.from(payload).toString('base64');
      // EnqueueするTaskの内容を設定する
      // https://cloud.google.com/tasks/docs/creating-http-target-tasks
      const task: google.cloud.tasks.v2.ITask = {
        httpRequest: {httpMethod, functionEndpoint, headers, body},
      };
      return client.createTask({parent, task});
    }));
  });

あとは、この関数をdeployすれば、定期的にCloud Tasksにキューが積まれていきます。

2. 各ページにLighthouseを実行しBiqQueryに結果を格納する

次に、Cloud TasksからHTTPリクエストを受けてLighthouseを実行する処理を実装します。

lighthouseを実行するためのブラウザにはpuppeteerを用います。

ここは少し罠があり、lighthouse公式のドキュメントにはChrome Launcherを利用する実装方法が書かれているのですが、私が試した限りではFirebase FunctionsではChrome Launcherを使用してLighthouseを実行するとエラーが発生しています。

lighthouse/readme.md at master · GoogleChrome/lighthouse · GitHub

実際の流れはこんな感じになるかと思います。

// index.ts
import * as functions from 'firebase-functions';
import lighthouse from "lighthouse";
import puppeteer from 'puppeteer';

// lighthouse実行するのでtimeoutSecondsを伸ばしておく
const runtimeOptions: functions.RuntimeOptions = {
  timeoutSeconds: 540,
  memory: '1GB',
};

const puppeteerFlags: Array<string> = [
  '--headless',
  '--disable-dev-shm-usage',
  '--disable-gpu',
  '--no-zygote',
  '--no-sandbox',
  '--single-process',
  '--hide-scrollbars',
];

const lighthouseFlags: LH.Flags = {
  output: 'json',
  emulatedFormFactor: 'mobile',
  // Performanceの点数に関係する項目だけに絞っています。
  onlyCategories: ['performance'],
  onlyAudits: [
    'first-contentful-paint',
    'first-meaningful-paint',
    'speed-index',
    'interactive',
    'first-cpu-idle',
  ],
};

export const auditUrl = functions
  .regions('asia-northeast1')
  .runWith(runtimeOptions)
  .https.onRequest(async (req, resp) => {
    await runLighthouseAndPersistResult(req.body.url);
    resp.sendStatus(200);
  });

async function runLighthouseAndPersistResult(url: string) {
  const result = await launchChromeAndRunLighthouse(url);
  // BiqQueryに結果を格納する処理
  // データ形式などは https://github.com/sahava/multisite-lighthouse-gcp を参考
  return insertLighthouseResult(result);
}

async function launchChromeAndRunLighthouse(url: string) {
  const browser = await puppeteer.launch({args: puppeteerFlags});
  const results = await lighthouse(url, {...lighthouseFlags, port: parseInt(new URL(browser.wsEndpoint()).port)});
  await browser.close();
  return results;
}

終わりに

今回はFirebase FunctionsからLighthouseを実行する構成例を簡単に紹介しました。

パフォーマンスを改善する上での第一歩は計測を始めることだと思うので、ぜひ皆さんもLighthouseを叩きまくってください。