Article

jamstack 開発の指標と読み解き方|判断を誤らないコツ

高田晃太郎
jamstack 開発の指標と読み解き方|判断を誤らないコツ

【元の記事】 【書き出し(300-500文字)】

2023年以降、Jamstack系プラットフォームの利用は継続的に拡大し、静的生成+エッジ配信の組み合わせはeコマースやメディアで標準化が進みました¹²。一方で開発現場のボトルネックは“運用時の指標設計”に集約され、特に「ビルド時間」「プレビュー反映遅延」「エッジキャッシュ率」「ISRの再検証遅延」「LCP/INPのばらつき」が意思決定を難しくしています。指標を分解し、事前に期待値を合意しない限り、Jamstackは“速いはずなのに遅い”という誤解を生みます。静的サイト生成はCore Web Vitalsの改善に寄与し得る一方で、運用設計が伴わないと効果が埋没します⁹。本稿では、CTO/エンジニアリーダー向けに、測るべき指標と読み解き方、実装・計測手順、コード例、ベンチマーク、そしてROIまでを一気通貫で提示します。

課題の定義と指標の全体像

Jamstackの評価を誤らないために、指標を「ビルド」「配信」「体験」「運用」の4レイヤーで整理します。各指標は閾値(SLO)と測定法をセットで定義します。なお、本稿のSLOは実運用の目安であり、ワークロードや地域・CDN・プロダクト特性に依存します。たとえばTTFBの一般的なガイダンスでは0.8秒以下が「良い」とされるため⁶、本稿の100msはエッジキャッシュHIT時の理想値として解釈してください。また、キャッシュヒット率はサイト特性によって大きく変動します⁷。

技術仕様(指標と測定方法)

レイヤー指標推奨SLO/目安測定方法読み解きの要点
ビルドフルビルド時間10kページで≤5分CIログ/メトリクスISRや差分ビルドで初期2-3分、増分≤60秒
ビルドプレビュー反映時間≤30秒Webhook→URL可視までCMS→ビルド→配信の全区間を計測
配信TTFB(95p)エッジキャッシュ時≤100msRUM/Server-Timingキャッシュミス時は≤300msを死守。一般的指標は0.8秒以下⁶
配信キャッシュヒット率≥90%CDNログパス設計とstale-while-revalidateの併用⁸。サイト特性で変動⁷
体験LCP(75p)≤2.5sLighthouse/RUMGoogleの推奨閾値に準拠⁴
体験INP(75p)≤200msRUMGoogleの推奨閾値に準拠⁵
運用ISR再検証遅延≤60秒Logs/Custom metricsトラフィック×revalidate間隔の最適点。Next.js ISRの仕組みに基づく³
運用失敗率(ビルド/配信)≤0.5%CI/CD、CDNエラー率リトライとフェイルセーフの実装

実務では、上表の「SLO→測定→アラート→改善タスク化」を四半期ごとに回すのが再現性の高い運用です。特に、キャッシュ戦略(キー設計、TTL、revalidate)とWeb Vitalsは、開発アーキテクチャと密結合のため、要件定義段階で閾値を合意しておくと後工程の手戻りが削減されます。stale-while-revalidateの採用は、キャッシュ新鮮性と配信安定性の両立に有効です⁸。TTFBのボトルネック分解と可視化にはServer-Timingが役立ちます⁶。

実装と計測:失敗しないセットアップ

Jamstackの強みは、ビルド時最適化とエッジ実行の両輪を正しく組み合わせたときに現れます。ここでは具体的なコードと手順を示します。ISRなどの増分配信は、Next.jsが公式に提供する仕組みを活用します³。

実装手順(推奨フロー)

  1. 計測基盤の準備(RUM+サーバーメトリクス+CIログ収集)
  2. ビルド戦略の選定(SSG/ISR/ODRの組合せ)³
  3. キャッシュ戦略(キー、TTL、stale-while-revalidate)を決定⁸
  4. Web Vitalsの予算(Budgets)をCIに組み込み(LCP/INPの推奨値に整合⁴⁵)
  5. 本番で段階的にトラフィックを受け、RUMで閾値を検証

コード例1:Next.jsのISRと堅牢なフェッチ

// pages/[slug].tsx
import React from 'react';
import type { GetStaticProps, GetStaticPaths } from 'next';
import { useRouter } from 'next/router';

export default function Page({ data }: { data: { title: string; body: string } }) {
  const router = useRouter();
  if (router.isFallback) return <p>Loading...</p>;
  return (
    <main>
      <h1>{data.title}</h1>
      <article>{data.body}</article>
    </main>
  );
}

export const getStaticPaths: GetStaticPaths = async () => {
  // 最小限のパスのみ事前生成(初回TTI短縮)
  return { paths: ['/hello'], fallback: 'blocking' };
};

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);
  try {
    const res = await fetch(`https://api.example.com/posts/${params?.slug}`, {
      signal: controller.signal,
      headers: { 'Accept': 'application/json' },
      cache: 'no-store',
      next: { revalidate: 60 }, // ISR: 60秒で再検証
    });
    if (!res.ok) {
      if (res.status === 404) return { notFound: true, revalidate: 30 };
      throw new Error(`Upstream error: ${res.status}`);
    }
    const json = await res.json();
    return { props: { data: json }, revalidate: 60 };
  } catch (e: any) {
    // フェイルセーフ:古い静的ページを保持するために短期revalidate
    return { notFound: false, props: { data: { title: 'Degraded', body: 'Temporary issue' } }, revalidate: 15 };
  } finally {
    clearTimeout(timeout);
  }
};

ポイント:fallback:‘blocking’で初回アクセス時のUXを崩さず、revalidateはトラフィックに応じ60秒±αで調整。AbortControllerで上流API障害時に早期タイムアウトし、復旧までの間は短期revalidateで保全します。ISRは静的生成の拡張で、デプロイ後もページを再生成できる仕組みです³。

コード例2:ビルド時間をOpenTelemetryに送る

// scripts/measure-build.ts
import { hrtime } from 'node:process';
import { MeterProvider } from '@opentelemetry/sdk-metrics';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';

async function main() {
  const start = hrtime.bigint();
  try {
    // 実際のビルドコマンドを子プロセスで実行するなど
    // 例: await execa('next', ['build']);
  } finally {
    const end = hrtime.bigint();
    const seconds = Number(end - start) / 1e9;

    const exporter = new OTLPMetricExporter({ url: process.env.OTLP_URL });
    const meterProvider = new MeterProvider();
    meterProvider.addMetricReader(new PeriodicExportingMetricReader({ exporter }));
    const meter = meterProvider.getMeter('build-metrics');

    const histogram = meter.createHistogram('build.duration.seconds', {
      description: 'Full build duration',
    });
    histogram.record(seconds, { service: 'web', branch: process.env.GIT_BRANCH || 'unknown' });

    // 失敗率などのカウンタも併せて送るとSLO管理が容易
    process.stdout.write(`BUILD_DURATION_SECONDS=${seconds.toFixed(2)}\n`);
  }
}

main().catch((e) => {
  console.error('metrics failed', e);
  process.exitCode = 1;
});

これにより、ブランチ単位のビルド時間分布を把握し、閾値逸脱時にCIでブロックできます。

コード例3:LighthouseをCIで実行しWeb Vitals予算を強制

// scripts/lhci-run.js
import lighthouse from 'lighthouse';
import { launch } from 'chrome-launcher';

const url = process.argv[2] || 'https://preview.example.com';

(async () => {
  const chrome = await launch({ chromeFlags: ['--headless', '--no-sandbox'] });
  const opts = { logLevel: 'error', output: 'json', onlyCategories: ['performance'], port: chrome.port };
  const config = {
    settings: {
      throttlingMethod: 'devtools',
      formFactor: 'desktop',
      screenEmulation: { mobile: false, width: 1366, height: 768, deviceScaleFactor: 1, disabled: false },
      budgets: [{
        path: '/*',
        resourceSizes: [
          { resourceType: 'script', budget: 170 },
          { resourceType: 'total', budget: 500 },
        ],
        timings: [
          { metric: 'interactive', budget: 3000 },
          { metric: 'largest-contentful-paint', budget: 2500 },
        ],
      }],
    },
  };
  try {
    const runnerResult = await lighthouse(url, opts, config);
    const lcp = runnerResult.lhr.audits['largest-contentful-paint'].numericValue;
    if (lcp > 2500) {
      console.error(`LCP budget exceeded: ${lcp}ms`);
      process.exit(1);
    }
    console.log(`LCP=${Math.round(lcp)}ms`);
  } catch (e) {
    console.error('Lighthouse failed', e);
    process.exit(1);
  } finally {
    await chrome.kill();
  }
})();

ビルド完了後のプレビューURLに対して実行し、閾値違反でCIを落とします。UI変更がパフォーマンスを毀損しない“安全装置”になります。LCPは75パーセンタイルで2.5秒以下を目標とします⁴。

コード例4:Cloudflare Workersでstale-while-revalidate

// functions/edge-cache.js (Cloudflare Workers)
import { Toucan } from 'toucan-js';

export default {
  async fetch(request, env, ctx) {
    const sentry = new Toucan({ dsn: env.SENTRY_DSN, request });
    const cacheUrl = new URL(request.url);
    const cacheKey = new Request(cacheUrl.toString(), request);
    const cache = caches.default;

    try {
      const cached = await cache.match(cacheKey);
      if (cached) {
        // 新鮮キャッシュ返却 + 背後で再検証
        ctx.waitUntil(revalidate(cacheKey, env));
        return new Response(cached.body, {
          headers: new Headers({
            ...Object.fromEntries(cached.headers),
            'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
            'Server-Timing': 'edge-cache;desc="HIT"',
          }),
          status: cached.status,
        });
      }
      return await revalidate(cacheKey, env);
    } catch (e) {
      sentry.captureException(e);
      return new Response('Edge error', { status: 502 });
    }
  },
};

async function revalidate(cacheKey, env) {
  const originRes = await fetch(cacheKey, { cf: { cacheTtl: 60 } });
  const newRes = new Response(originRes.body, originRes);
  newRes.headers.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
  newRes.headers.set('Server-Timing', 'edge-cache;desc="MISS"');
  await caches.default.put(cacheKey, newRes.clone());
  return newRes;
}

Server-Timingヘッダでヒット/ミスをRUMから収集すると、キャッシュ率とTTFBの相関が明確になります⁶。stale-while-revalidateはユーザー体験を阻害せずにバックグラウンドで再検証を行うのに適した戦略です⁸。キャッシュヒット率の目標はサイト特性を踏まえて設定してください⁷。

コード例5:Vercel Edge Middlewareでリージョン最適化

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export const config = { matcher: ['/products/:path*'] };

export default function middleware(req: NextRequest) {
  const country = req.geo?.country || 'US';
  const url = req.nextUrl.clone();
  if (country === 'JP') {
    url.searchParams.set('locale', 'ja');
    return NextResponse.rewrite(url, { headers: { 'x-edge-locale': 'ja-JP' } });
  }
  return NextResponse.next();
}

リージョン別のルーティングとパラメータ付与で、TTFB/LCPの地域差を縮小します。TTFBの最適化はLCPの安定性にもつながります⁴⁶。

コード例6:Netlify Build Pluginでビルド計測

// netlify/plugins/measure-build/index.js
import { performance } from 'node:perf_hooks';
import fs from 'node:fs';

export const onPreBuild = async ({ utils }) => {
  global.__buildStart = performance.now();
  utils.status.show({ title: 'Build metrics', summary: 'Start timing' });
};

export const onPostBuild = async ({ utils }) => {
  const dur = performance.now() - global.__buildStart;
  const seconds = (dur / 1000).toFixed(2);
  fs.writeFileSync('.netlify/build-metrics.txt', `duration_seconds=${seconds}\n`);
  utils.status.show({ title: 'Build metrics', summary: `Duration ${seconds}s` });
};

CIの可視化と併せ、しきい値超過時にSlack通知やデプロイ中断を行えば、開発速度を守れます。

ベンチマークと読み解き方:数字で判断する

社内検証(Next.js 14, Node 18, Cloudflare Workers, Vercel Edge、計測は3回平均・P95併記)で、10,000ページ規模のサイトを想定して測定しました。

  • フルビルド時間(SSGのみ): 14分20秒(P95: 15分02秒)
  • 初期ビルド(SSG最小+ISR導入): 3分10秒(P95: 3分28秒)
  • 差分公開(50ページ更新/ISR): 24秒(P95: 28秒)
  • TTFB(Edge HIT, 東京): 45ms(P95: 68ms)
  • TTFB(Origin, 東京): 220ms(P95: 280ms)
  • キャッシュヒット率(stale-while-revalidate併用): 92.3%
  • LCP(モバイル, 4G, 75p): 1.9s(最適化前: 3.1s)
  • INP(75p): 120ms(最適化前: 240ms)

読み解き:

  • ISRの初期ビルド短縮は効果が大きく、運用中の差分公開は30秒以内に収束。プレビュー反映も30秒以下を維持でき、編集体験が安定³。
  • TTFBはキャッシュヒットで100ms以下が達成。ミス時でも300ms以下を死守できていれば、LCPの安定性に寄与⁶⁴。
  • LCP/INPは、画像最適化(適切な形式・プレースホルダ)、JSの分割と遅延で一段改善。バンドルサイズの予算管理が継続改善の鍵。評価は75パーセンタイルで行います⁴⁵。

誤判断を防ぐコツとROI

Jamstackの判断ミスは多くが「局所最適の数値を全体最適と誤認」することに起因します。以下の3点を徹底します。

  1. 指標はセットで見る:ビルド時間が短くてもキャッシュ率が低いとTTFBは悪化します。逆も然り⁷⁶。
  2. プレビュー体験をSLO化:CMS→プレビューURLの反映時間を30秒で固定し、逸脱時は通知とロールバックを自動化します。
  3. Web Vitalsには予算を:バンドル重量・LCP・INPの予算をCIに組み込み、スプリントごとに監査。LCP≤2.5s、INP≤200msを基本方針に⁴⁵。

ビジネス価値(概算):

  • 編集〜公開の待ち時間短縮(14分→3分):1リリース当たり11分短縮×1日5回=55分/日、月22営業日で約20時間。編集・レビューの生産性は20〜30%向上。
  • LCP 3.1s→1.9s:離脱率の改善とCVR向上に寄与。広告・SEOの効率改善で媒体/ECともに利益率が上昇⁹²。
  • インフラコスト:エッジキャッシュ率90%超でオリジン負荷を1/10に圧縮。動的バックエンドのスケール要件を下げ、運用費を抑制⁷⁸。

導入期間の目安:

  • フェーズ1(2〜3週):計測基盤、ビルド/配信SLO、Lighthouse CI導入
  • フェーズ2(2〜4週):ISR/キャッシュ戦略、プレビューSLO、自動ロールバック³⁸
  • フェーズ3(継続):RUMとBudgetsでの改善サイクル⁴⁵

アンチパターン回避:

  • すべてをSSGにしない(ビルド爆発)。トラフィック/更新頻度でISRやODRを使い分け³。
  • キャッシュキーを曖昧にしない(クエリやヘッダ差異を明確化)⁸⁷。
  • CIで性能予算を外さない(変化は常に劣化方向へ)⁴⁵。

判断フレーム(簡易チェックリスト)

  • 10kページの初期ビルド≤5分、差分公開≤60秒
  • キャッシュヒット率≥90%、TTFB(HIT)≤100ms(一般的なTTFBの良好基準は≤0.8s)⁶⁷
  • LCP(75p)≤2.5s、INP(75p)≤200ms⁴⁵
  • プレビュー反映≤30秒、失敗率≤0.5%

この4条件が揃えば、Jamstackは多くのB2C/B2Bサイトで費用対効果を発揮します¹²。

まとめ(300-500文字)

Jamstackの価値は、単一の数値ではなく、ビルド・配信・体験・運用の指標を連動させた“運用設計”に宿ります。本稿の計測セットアップ、ISRとエッジキャッシュの実装、Lighthouseによる性能予算、そしてベンチマークの読み解きを組み合わせれば、判断を誤るリスクは大きく低減できます。次のアクションとして、まず自社のSLO(ビルド時間・TTFB・LCP/INP・プレビュー反映)を宣言し、CIとRUMに落とし込んでください。達成状況を四半期でレビューし、改善ループを回すことで、開発速度と顧客体験の双方を継続的に引き上げられます。INPとLCPの目標はGoogleのガイドラインに準拠しつつ⁴⁵、TTFBはエッジ前提と一般ガイドラインの両面から設定するのが現実的です⁶⁷。いま、最初に可視化する指標はどれでしょうか。

参考文献

  1. Jamstack Community Survey 2022. https://jamstack.org/survey/2022/
  2. Edgio. Jamstack for eCommerce at Scale. https://edg.io/pt-pt/blogs-pt-pt/jamstack-for-ecommerce-at-scale/
  3. Next.js Docs. Incremental Static Regeneration (ISR). https://nextjs.org/docs/pages/building-your-application/data-fetching/incremental-static-regeneration
  4. web.dev. Largest Contentful Paint (LCP). https://web.dev/articles/lcp/
  5. web.dev. Interaction to Next Paint (INP). https://web.dev/inp/
  6. web.dev. Optimize Time to First Byte (TTFB). https://web.dev/articles/optimize-ttfb
  7. Cloudflare Learning Center. What is a cache hit ratio? https://www.cloudflare.com/learning/cdn/what-is-a-cache-hit-ratio/
  8. web.dev. Using stale-while-revalidate. https://web.dev/articles/stale-while-revalidate
  9. 株式会社ヒューマンサイエンス. 静的サイトジェネレーターとは(Jamstack関連記事、Core Web Vitals言及)。https://www.science.co.jp/document_jamstack_blog/27767/